toopo

number/parse

  • stable contract
  • TS · npm

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

npx toopo add number/parse

4 299 bytes, one file

0 imports

50 settled cases

Frozen contract

The signature and behaviour of parse will never change — not in a minor release, not in a major one. Updates may only fix internal defects while preserving the contract, held to its 50 settled cases on every commit.

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.

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

Source

lib/toopo/number/parse.ts

// typescript/number/parse@1 - https://toopo.dev/typescript/number/parse@1/
// Copyright (c) 2026 Mathis Perron. SPDX-License-Identifier: MIT-0

/**
 * `number/parse@1` - convert a string to a finite number, or `null` when the string is not a decimal
 * number.
 *
 * Written to be read: correctness and obviousness come before speed.
 *
 * Failure is `null`, and `describeParseFailure` publishes the reason beside it. Both derive from one
 * private analysis, so this module holds a single grammar and the two exports cannot drift.
 */

/**
 * The decimal grammar this contract accepts, anchored at both ends: an optional sign, then either
 * digits with an optional fractional part or a bare fractional part, then an optional exponent.
 *
 * The grammar is matched before conversion rather than after, because `Number` accepts a wider
 * language than this contract does - hexadecimal, binary, octal, the empty string, "Infinity" -
 * and there is no way to tell those apart from a legitimate decimal by looking at the result.
 *
 * `\d` matches ASCII 0-9 only in JavaScript regular expressions, which is what rejects non-ASCII
 * digits such as the Arabic-Indic U+0661 U+0662 U+0663.
 */
const DECIMAL_GRAMMAR = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/

/**
 * The second look that tells a separator mistake from text that is not a number at all.
 *
 * It is the grammar above consulted twice rather than a second grammar: whatever survives the
 * removal is judged by exactly the same rule, so there is no additional thing that can be wrong and
 * nothing for the coupling property to fail to cover.
 *
 * The family is the comma, the underscore, the apostrophe, the no-break space and the narrow no-break
 * space - the grouping characters `Intl.NumberFormat` emits, measured over 108 locales. The ordinary
 * space is not one of them: no locale emits it between digits, so it is a typo rather than formatting.
 *
 * The characters are escaped rather than pasted because three of them are invisible, and the literal
 * is written inside the call so that this module holds no global-flagged regular expression whose
 * lastIndex could survive a call.
 */
const withoutSeparators = (input: string): string => input.replace(/[,_'\u00A0\u202F]/g, '')

type ParseFailureReason = 'empty' | 'separator' | 'not-decimal' | 'overflow'

/**
 * The single source both exports derive from, private because it is not the shape this module
 * publishes: a caller sees `number | null` and asks for the reason only when it needs one.
 *
 * One function rather than two, so that a string fails to parse exactly when it has a description -
 * there is one grammar here, so the two exports cannot disagree about which strings fail. It also
 * keeps one public call to one traversal: defining `parseNumber` through `describeParseFailure` would
 * trim and convert twice on every accepted input.
 */
type ParseAnalysis =
  | { readonly ok: true; readonly value: number }
  | { readonly ok: false; readonly reason: ParseFailureReason }

const analyse = (input: string): ParseAnalysis => {
  const trimmed = input.trim()
  if (trimmed === '') return { ok: false, reason: 'empty' }
  if (!DECIMAL_GRAMMAR.test(trimmed)) {
    return DECIMAL_GRAMMAR.test(withoutSeparators(trimmed))
      ? { ok: false, reason: 'separator' }
      : { ok: false, reason: 'not-decimal' }
  }

  const value = Number(trimmed)

  // Overflow is the one failure the grammar cannot express: "1e400" is a well-formed decimal that
  // has no finite double. Underflow ("1e-400" -> 0) stays finite and is accepted.
  return Number.isFinite(value) ? { ok: true, value } : { ok: false, reason: 'overflow' }
}

export const parseNumber = (input: string): number | null => {
  const analysis = analyse(input)

  return analysis.ok ? analysis.value : null
}

/**
 * Why a string is not a decimal number, or `null` when it is one.
 *
 * A caller that asks for the reason after a refusal repeats the whole analysis. That is affordable
 * here - a trim and a regular expression - and it is the price of keeping the answering path free of
 * a wrapper no caller asked for.
 */
export const describeParseFailure = (input: string): ParseFailureReason | null => {
  const analysis = analyse(input)

  return analysis.ok ? null : analysis.reason
}

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. 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.