typescript/number/parse@1 — Convert a string to a finite number, or null when the string is not a decimal number.

50 named edge cases, settled and frozen. TypeScript source copied into your project: one file, 4 299 bytes, no dependencies.

[Toopo](../../../)

# typescript/number/parse@1

Convert a string to a finite number, or null when the string is not a decimal number.

```
toopo add number/parse
```

One file, 4 299 bytes, copied into your project. It imports nothing. You get parseNumber and describeParseFailure.

## What it does

Converts a string to a number in JavaScript and TypeScript, returning null instead of a misleading value when the string is not a number. JavaScript offers three built-in ways to convert a string to a number and all three lie in common cases: Number("") returns 0, Number(" ") returns 0, Number("0x1F") returns 31, parseFloat("1.2.3") returns 1.2 and parseInt("1e3") returns 1. Each of them can also return NaN, a value that survives every arithmetic operation and surfaces far away from the mistake that produced it. This contract accepts the decimal grammar a human writes - optional surrounding whitespace, optional sign, leading zeros, a fractional part, scientific notation - and rejects everything else, including hexadecimal, octal, binary, digit separators, "NaN" and "Infinity". The result is either null or a finite number: never NaN, never Infinity. Which of the four refusals happened is published by a second export rather than folded into the return value, so a caller who only needs a number is not made to unwrap one. One of the four is that the text carries digit separators - "1,5", "1 000", "1'000" - which is what a French, German, Swiss or Nordic spreadsheet exports, and a caller holding that reason can correct its user instead of telling them their number is not a number.

## What it is for, and what it is not

Human-authored decimal text: form fields, CSV and spreadsheet cells, CLI arguments, environment variables, query-string parameters, and text pasted out of documents. It is not a reader for JavaScript source literals, not a locale-aware parser, and not an arbitrary precision parser.

## Signature

```
type ParseNumber = (input: string) => number | null
```

```
type DescribeParseFailure = (input: string) => ParseFailureReason | null
```

A call fails for one of 4 reasons, and the set is frozen with the major version: "empty", "separator", "not-decimal", "overflow".

parseNumber(s) === null if and only if describeParseFailure(s) !== null, for every string s.

## 50 settled cases

Every one of them is named, frozen with the major version, and linkable. This is what the contract decides, one input at a time.

the inputs this contract settles one at a time, because a grammar has no algebra.

### Baseline

`parseNumber('42') → expected 42, reason null`

An ordinary integer parses to itself.

`parseNumber('-3.5') → expected -3.5, reason null`

An ordinary negative decimal parses to itself.

### Whitespace

`parseNumber('  42  ') → expected 42, reason null`

Surrounding whitespace is ignored, because text arriving from a form field or a spreadsheet cell routinely carries it. Trimming uses String.prototype.trim.

`parseNumber('\t\n 7 \r\n') → expected 7, reason null`

Tabs and newlines are whitespace for String.prototype.trim, so they are ignored too.

A text field drops \\n and \\r, so the input field cannot be retyped in the playground below. The answer above is what this contract settles for it.

`parseNumber('\uFEFF9') → expected 9, reason null`

A leading byte-order mark parses, because String.prototype.trim removes U+FEFF: "\\uFEFF9".trim() has length 1. This is derived from the whitespace rule rather than a special case, and it matters because text copied out of a document often carries a byte-order mark or a non-breaking space.

`parseNumber('4 2') → expected null, reason 'not-decimal'`

Whitespace inside the number is not ignored; only leading and trailing whitespace is.

### Sign

`parseNumber('+42') → expected 42, reason null`

A leading plus sign is accepted and has no effect on the value.

`parseNumber('- 1') → expected null, reason 'not-decimal'`

A sign separated from its digits is not a number.

`parseNumber('--1') → expected null, reason 'not-decimal'`

A repeated sign is not a number.

`parseNumber('-0') → expected -0, reason null`

Negative zero is preserved, because it is a distinct IEEE-754 value carrying the sign of an underflowing computation. Comparing results with === would hide the difference, since -0 === 0 is true; this contract compares with Object.is.

### Digit shapes

`parseNumber('01') → expected 1, reason null`

Leading zeros are accepted and carry no meaning. They are not read as octal - that legacy of old parseInt implementations is not part of this contract.

`parseNumber('.5') → expected 0.5, reason null`

A fraction with no integer part is accepted, matching how people write it.

`parseNumber('5.') → expected 5, reason null`

An integer with a trailing decimal point is accepted; it is a common typing artefact.

`parseNumber('.') → expected null, reason 'not-decimal'`

A decimal point with no digits on either side is not a number.

`parseNumber('1.2.3') → expected null, reason 'not-decimal'`

Two decimal points is not a number. parseFloat("1.2.3") returns 1.2, silently discarding the rest of the input; this contract rejects it instead.

### Exponent

`parseNumber('1e3') → expected 1000, reason null`

Scientific notation is accepted, because it is how spreadsheets and scientific exports write large numbers. parseInt("1e3") returns 1, keeping only the leading digit.

`parseNumber('1E+3') → expected 1000, reason null`

The exponent marker is case-insensitive and its sign is optional.

`parseNumber('1e-7') → expected 1e-7, reason null`

A negative exponent is accepted.

`parseNumber('1e') → expected null, reason 'not-decimal'`

An exponent marker with no digits is not a number. parseFloat("1e") returns 1, silently dropping the incomplete exponent.

### Empty and blank

`parseNumber('') → expected null, reason 'empty'`

The empty string is not a number. Number("") returns 0, the single most damaging trap in JavaScript numeric conversion: an empty form field becomes a legitimate-looking zero. Correcting it is the main reason this contract exists.

`parseNumber('   ') → expected null, reason 'empty'`

A blank string is not a number, for the same reason: Number("   ") also returns 0.

### Non-finite words

`parseNumber('NaN') → expected null, reason 'not-decimal'`

The word "NaN" is not a number. Number("NaN") returns NaN, which is indistinguishable from the failure value of every other invalid input.

`parseNumber('Infinity') → expected null, reason 'not-decimal'`

The word "Infinity" is rejected, because a parser that can return an infinite value forces every caller to guard with isFinite afterwards. Number("Infinity") returns Infinity and Python accepts float("Infinity"); this contract knowingly diverges from both. It is not-decimal rather than overflow: the rejection is about the word, not about a magnitude that was computed and lost.

`parseNumber('-Infinity') → expected null, reason 'not-decimal'`

Rejected for the same reason as "Infinity".

### Alternative radixes

`parseNumber('0x1F') → expected null, reason 'not-decimal'`

Hexadecimal is rejected: this contract reads human decimal text, where "0x1F" is a typo rather than the number 31. Number("0x1F") returns 31, but JavaScript's own numeric parser disagrees - parseFloat("0x1F") returns 0 - and Python raises on float("0x1F").

`parseNumber('0o17') → expected null, reason 'not-decimal'`

Octal notation is rejected, consistently with hexadecimal.

`parseNumber('0b11') → expected null, reason 'not-decimal'`

Binary notation is rejected, consistently with hexadecimal.

### Separators: the characters the family covers

Every character below was measured rather than chosen: Intl.NumberFormat was asked to format 1234567.5 in 108 locales, and these are the grouping characters it emitted. The value is still refused - this contract is not locale-aware and will not guess which separator was decimal - but the reason names what the writer did, instead of calling their number "not a number".

`parseNumber('1,5') → expected null, reason 'separator'`

A comma is never a decimal separator here, and this is the most frequent real refusal in half of Europe: measured, 61 of 108 locales write the decimal point as a comma. The value is refused because reading it as 1.5 or as 15 would both be guesses about the writer, but the reason is not a guess - the text does carry a separator, and a caller can say so.

`parseNumber('1,000') → expected null, reason 'separator'`

A thousands separator, and the exact input that makes guessing indefensible: 1,000 is one thousand in English and one in French. The refusal is the answer; the reason is the repair.

`parseNumber('1_000') → expected null, reason 'separator'`

The underscore is the one member of the family no locale emits. It is here because it is the digit separator of JavaScript and Python source literals: Number("1\_000") returns NaN while Python's float("1\_000") returns 1000, so a developer who expects the Python answer gets a named reason rather than the residual one.

`parseNumber('1,234.56') → expected null, reason 'separator'`

Grouping and a decimal point together, the shape a spreadsheet exports in English. It is listed because it is the case where the separator and the grammar meet: the full stop is a token this grammar reads and the comma is not, so only the comma is removed before the second look.

`parseNumber('1\'000') → expected null, reason 'separator'`

The apostrophe groups digits in Swiss and Liechtenstein formatting - measured, de-CH, de-LI, it-CH, gsw, wae and en-CH all emit it. It is the only member of the family that is an ordinary typeable character, and it is included because no measured locale emits it for anything else between two digits.

`parseNumber('1\u00A0000') → expected null, reason 'separator'`

A no-break space groups digits in 56 of the 108 locales measured, among them Swedish, Czech, Polish and Russian. It survives a copy out of a document and is invisible to the person who pasted it, which is exactly the population that cannot diagnose a bare refusal.

`parseNumber('1\u202F000,5') → expected null, reason 'separator'`

What a French spreadsheet exports: a narrow no-break space grouping and a comma decimal, both in one string. Measured, fr, fr-BE, fr-CH and rm-CH emit U+202F and not the no-break space, so covering one and not the other would miss the locale the literal exists for.

`parseNumber('1,2,3') → expected null, reason 'separator'`

Grouping that is not in threes is still a separator mistake. This case was cited by an earlier revision of this contract as the reason the literal could not exist - the claim being that classifying it would need a second grammar that knows groups come in threes. The claim was wrong about what the reason has to mean: "this text uses digit separators" is true of 1,2,3 whether or not the grouping is well formed, and no caller acts on the difference.

`parseNumber('1.000,5') → expected null, reason 'separator'`

German and Italian grouping: the full stop groups and the comma is decimal. Removing the family from this input leaves 1.0005, which is not what the writer meant - and the reason is still correct, because it names the motive and not the repair. A caller that echoes "remove the separators" would be wrong here; one that says "this contract reads 1000.5, not 1.000,5" is right. The literal is a diagnosis, never a rewrite rule.

### Separators: the characters the family does not cover

`parseNumber('1 000') → expected null, reason 'not-decimal'`

An ordinary space is not formatting. No measured locale emits U+0020 between digits - the ones that group with a space emit U+00A0 or U+202F - so a space here is a typo, and it carries the residual reason for the same purpose that 4 2 does. This is the line that makes the family a decision rather than a habit: it is drawn where the measurement draws it, and a mutant that widens the family to whitespace is caught by this case.

`parseNumber('1’000') → expected null, reason 'not-decimal'`

The typographic apostrophe is excluded, and it is the exclusion this contract is least sure of. A word processor turns a typed apostrophe into U+2019, so the character does reach real text; but measured, no locale emits it as a group separator, and the family is drawn from the measurement rather than from what seems likely. Recorded here so that a later revision has the reason it would be overturning.

`parseNumber('1٬234') → expected null, reason 'not-decimal'`

The Arabic thousands separator is excluded on a measurement rather than a preference: the locales that emit U+066C emit Arabic-Indic digits with it, and this contract rejects those digits anyway. Adding it to the family would create a branch that no formatted number can reach, which is the shape of a guard that cannot fail.

`parseNumber(',') → expected null, reason 'not-decimal'`

A separator with nothing to separate is not a number with separators in it. Removing the family leaves the empty string, which the grammar refuses, so the residual reason is reached - and \`empty\` is not, because the input was not blank to begin with.

`parseNumber('x,y') → expected null, reason 'not-decimal'`

A comma inside something that is not a number at all. It is the other side of the same boundary: the reason is decided by what is left once the family is removed, not by whether a family character appears.

`parseNumber('0x1_F') → expected null, reason 'not-decimal'`

A separator does not rescue a radix prefix. Removing the underscore leaves 0x1F, which the grammar refuses, so this stays the residual refusal - the same answer it would get without the underscore, which is the point.

### Not numbers at all

`parseNumber('abc') → expected null, reason 'not-decimal'`

Arbitrary text is not a number.

`parseNumber('12n') → expected null, reason 'not-decimal'`

A BigInt literal suffix is rejected. parseFloat("12n") returns 12, silently discarding the suffix that carried the meaning.

`parseNumber('١٢٣') → expected null, reason 'not-decimal'`

Digits outside ASCII 0-9 are rejected. Number() also returns NaN for the Arabic-Indic digits U+0661 U+0662 U+0663, even though they spell 123.

`parseNumber('constructor') → expected null, reason 'not-decimal'`

An inherited object property name is not a number. It is listed because an implementation memoising into a plain object serves it from Object.prototype: "constructor" in {} is true. Which of the two exports catches that depends on the error convention, and under this one it is no longer the value: measured, parseNumber("constructor") answers null, which is correct, while describeParseFailure answers undefined where this row requires "not-decimal". Block 4.2 records why that matters to every later contract.

### IEEE-754 limits

`parseNumber('1e400') → expected null, reason 'overflow'`

A value too large for a double is rejected, because Number("1e400") returns Infinity and the contract guarantees a finite result. The magnitude is lost either way; returning null makes the loss impossible to ignore.

`parseNumber('1e-400') → expected 0, reason null`

A value too small for a double becomes 0, the nearest representable double under IEEE-754. Unlike overflow this stays finite, so it is accepted - the contract cannot repair IEEE-754, only make its behaviour explicit.

`parseNumber('-1e-400') → expected -0, reason null`

A negative underflow becomes -0, keeping the sign of the value that was lost. This is where preserving negative zero pays for itself.

`parseNumber('9007199254740993') → expected 9007199254740992, reason null`

Above 2^53 consecutive integers are no longer representable, so this input parses to the nearest double, 9007199254740992. Every JavaScript number parser loses this digit; the contract documents the loss rather than pretending otherwise.

## Try it on your own input

This calls parseNumber on whatever you type. What you type into a field is the value, character for character, and the form opens on ordinary-integer so there is a call that works to edit. What comes back is what the function answered, under the call it was made from — invisible characters are named there, so two inputs that look alike on screen do not print alike. The settled answer is on the case's own line above, and is deliberately not repeated here. When it answers nothing, describeParseFailure is called on the same input and its reason is printed underneath: the two exports are one surface, and every input this contract turns down answers parseNumber alike.

The JavaScript this runs is number/parse's own reference.ts with its types stripped. That is neither the file the registry serves nor the file its digest covers: both are TypeScript, and no browser runs TypeScript. It is also the only part of this page that needs JavaScript at all.

## Properties

Every property below is checked on 1 000 generated cases per run, re-seeded each time.

- `never mutates its arguments — not applicable`

  The signature takes a single \`string\`, a primitive that is immutable by construction in JavaScript. No implementation, correct or broken, can violate this, so a test asserting it would be structurally incapable of failing.
- `deterministic — checked`

  Violable in practice, and witnessed by P-08 of the battery: a regular expression carrying the global flag keeps a lastIndex between calls and answers differently on the second one. This property is ordered under \`no ambient input\` rather than independent of it: every mutant measured to redden it reddens that one too, and the memoise-last mutant reddens that one and not this. P-21 is that mutant here.
- `no ambient input — checked`

  Violable in practice: a cache keyed on the wrong thing, or a regular expression whose lastIndex survives a call, makes an answer depend on which inputs were parsed before it. The property interleaves a probe with an arbitrary history and requires the probe to answer identically either way. This contract reads a string and returns a number, so the call history is the only ambient input it can plausibly acquire. Witnessed alone by P-21, which remembers its last analysis under the length of the input: measured, ten guards redden and determinism is not one of them. The caches that are consulted first and never advance - P-02, P-17, P-19 - stay invisible to it, because the probe primes them itself.
- `no ambient output — not applicable`

  Not reachable by a property - a test cannot observe a write that happened before it ran, and a correct memoising cache is indistinguishable from a defect by behaviour alone. Measured here first, on the implementation that writes globalThis.\_\_parseNumberCalls and passes the whole suite, and the attempt was removed rather than left decorative.

## Benchmark profiles

The shapes of input an implementation is timed on. No figures yet: there is no reference machine, and a number produced on a developer laptop would be a number with nothing behind it.

- `small-integers — accepted`

  Short integer strings, the dominant shape in form and query-string input.
- `decimals-and-exponents — accepted`

  Fractional and scientific notation, the shape of spreadsheet and CSV exports.
- `rejected-inputs — rejected`

  Inputs that must return null. Measured separately because an implementation may take a very different path when it rejects, and validation-heavy callers hit that path most.
- `whitespace-padded — accepted`

  Valid numbers wrapped in whitespace, isolating the cost of trimming.
- `long-inputs — accepted`

  Pathological lengths, to expose implementations whose cost grows faster than linearly. The thousand digits sit behind the decimal point so that the value stays finite and the sample stays on the accepting path; written as an integer it overflows and measures rejection.

## What you can check yourself

This definition is frozen. Its canonical text hashes to 66ed1f87f909fa0f5d4ff5eb5721275177a731fee90c33db3f7d58ffc0682768, and the 7 files of its test harness are listed inside it with their own hashes — so a copy of the harness can be checked against this definition before it is trusted, then run against any implementation, without taking our word for any of it.

Written for node, browser, bun.
