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:
- Integer NORAD ID (
25544)
- Alpha-5 designator (
A0123)
- 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.
- Rust —
try_X returns Option<T> alongside X that panics.
- Go —
ParseX 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:
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
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).
Summary
Add a
tryDecode(s)function alongsidedecodethat returnsnullinstead of throwing when the input isn't a valid NORAD designator. Pure additive change — non-breaking, minor version bump (v1.1.0).Equivalent to the inline wrapper consumers write today:
Motivation — real use case in descent-app
The first consumer of
alpha5has a satellite search that needs to handle three input modes from a single search box:25544)A0123)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:
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: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+) — returnsURL | null. Companion tonew URL()which throws.Number.parseInt/Number.parseFloat— returnNaN, never throw.try_XreturnsOption<T>alongsideXthat panics.ParseXreturns(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:
This is correctly distinguishing codec API from application logic.
tryDecodeis 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.
tryDecodeis 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
Type declaration:
Test plan
Tests should mirror the bound conditions of
decodebut assertnullinstead oftoThrow:nullfor: empty string, non-string types, reserved letters (I/O), lowercase letters, whitespace/sign/decimal/hex/oct/bin, mid-string non-digit chars, values exceeding339_999.tryDecodeof any input thatdecodethrows on should returnnull. 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
decodeandencodeconsumers see no change.CHANGELOG.mdentry under "## [1.1.0]" describing the new export and use case.CLAUDE.md\invariants list: `tryDecode(s) === decode(s)` on inputsdecodeaccepts;tryDecode(s) === nullon inputsdecodethrows.Acceptance criteria
tryDecodeexported fromsrc/index.jsand declared insrc/index.d.ts.decodeon every input.npm run lintandnpm testpass.package.json.Out of scope
decodeor changing its throw behavior (would be breaking — v2.0).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).