Skip to content

Add tryDecode(s): number | null — exception-free variant of decode #1

Description

@elfensky

Summary

Add a tryDecode(s) function alongside decode that returns null instead of throwing when the input isn't a valid NORAD designator. Pure additive change — non-breaking, minor version bump (v1.1.0).

import { tryDecode } from 'alpha5';

tryDecode('A0123');   // 100123
tryDecode('25544');   // 25544
tryDecode('Hubble');  // null     (would throw via decode)
tryDecode('I0000');   // null     (reserved letter)
tryDecode('999999');  // null     (out of range)

Equivalent to the inline wrapper consumers write today:

function tryDecode(s) {
    try { return decode(s); } catch { return null; }
}

Motivation — real use case in descent-app

The first consumer of alpha5 has a satellite search that needs to handle three input modes from a single search box:

  1. Integer NORAD ID (25544)
  2. Alpha-5 designator (A0123)
  3. Satellite name (Hubble)

For modes 1 and 2 we decode and do an exact-match lookup; for mode 3 we fall through to a substring name search. Today this requires the try/catch ceremony at every search call site:

let exactNoradMatch = null;
try {
    exactNoradMatch = decode(query);
} catch {
    // not a valid NORAD designator → name search only
}

Search is inherently a "is this valid, and if so what's the value?" question. Exceptions for control flow are awkward here — they imply error handling for a perfectly legitimate input class (non-designator strings are expected in a search box).

With tryDecode:

const exactNoradMatch = tryDecode(query);

Cleaner at the call site, no try/catch, intent is direct.

Standard-library precedent

The "throwing variant + null-returning variant" pattern is well-established for parsers/codecs:

  • URL.parse (Node 22+, browsers 2024+) — returns URL | null. Companion to new URL() which throws.
  • Number.parseInt / Number.parseFloat — return NaN, never throw.
  • Rusttry_X returns Option<T> alongside X that panics.
  • GoParseX returns (T, error) rather than panicking.

The convention exists because parsing is a domain where invalid input is a normal case, not exceptional.

Why this is allowed despite CLAUDE.md's "do not add new exports" rule

The rule in CLAUDE.md reads:

If a use case seems to want a third function, it's almost certainly application logic that belongs in the caller (see `tleBatchNumber` in descent-app — it's S3-partition logic, NOT part of this lib).

This is correctly distinguishing codec API from application logic. tryDecode is unambiguously codec API — it's a presentation of the same decoder with different error semantics. It's not application logic.

The deeper intent of the rule is to block speculative API growth. tryDecode is the opposite: a concrete consumer (search) has the exact try/catch pattern this function eliminates, with a clean equivalence-class definition ("return null for everything that would otherwise throw").

If we'd seen this use case before publishing v1.0, we'd have shipped it then.

Implementation sketch

// src/index.js — add after the existing decode export

/**
 * Decode a NORAD designator string, returning \`null\` for any input that
 * would cause \`decode\` to throw.
 *
 * Useful when invalid input is a normal case — e.g. a search box where the
 * user might type a name, an integer, or an Alpha-5 designator and you
 * want to handle all three without try/catch ceremony.
 *
 * @param {string} s
 * @returns {number | null}
 *
 * @example
 *   tryDecode('A0123');   // 100123
 *   tryDecode('Hubble');  // null
 *   tryDecode('999999');  // null  (out of range)
 */
export function tryDecode(s) {
    try {
        return decode(s);
    } catch {
        return null;
    }
}

Type declaration:

// src/index.d.ts
export declare function tryDecode(s: string): number | null;

Test plan

Tests should mirror the bound conditions of decode but assert null instead of toThrow:

  • Returns the decoded integer for every valid input class (plain numeric, Alpha-5, both padded and unpadded).
  • Returns null for: empty string, non-string types, reserved letters (I/O), lowercase letters, whitespace/sign/decimal/hex/oct/bin, mid-string non-digit chars, values exceeding 339_999.
  • Specifically: tryDecode of any input that decode throws on should return null. The two should agree on every input.

A property-based test asserting \tryDecode(s) === (decode-or-null)(s)`` across the existing test corpus would be the cleanest pin.

SemVer + release

  • Minor version bump: v1.0.0 → v1.1.0.
  • Non-breaking — existing decode and encode consumers see no change.
  • CHANGELOG.md entry under "## [1.1.0]" describing the new export and use case.
  • README "## API" section updated with the new function.
  • CLAUDE.md\ invariants list: `tryDecode(s) === decode(s)` on inputs decode accepts; tryDecode(s) === null on inputs decode throws.

Acceptance criteria

  • tryDecode exported from src/index.js and declared in src/index.d.ts.
  • Tests cover both success and null-returning paths; agree with decode on every input.
  • npm run lint and npm test pass.
  • README and CHANGELOG updated.
  • Version bumped to 1.1.0 in package.json.
  • CI green on Node 20/22/24.
  • Published to npm.

Out of scope

  • Renaming decode or changing its throw behavior (would be breaking — v2.0).
  • Adding tryEncode (encode's input space is already narrow; `typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 339999` is a 1-line guard that callers can write inline more cheaply than a try/catch wrapper would save).
  • Error subclasses (still out, per CLAUDE.md).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions