TypeScript String to Number

October 6, 202610 min read
Requirements:
Functions| UnionsObjects

Converting a TypeScript string to number is a one-liner in four different ways, and all four of them lie to you in the same place: the return type is number, even when the conversion fails. Number('abc') compiles without a complaint and hands back NaN, which the compiler is perfectly happy to call a number. That is the whole problem, and it is the reason this page is longer than one line.

Here is the shape of it:

declare const rawAge: string
 
const parsedAge = Number(rawAge) // number — even when rawAge is 'banana'

No error, no warning, no union. Below: what each conversion actually does, which one to reach for, and how to write a parse that forces the caller to handle failure.

The four ways to convert a TypeScript string to number

const priceText = '42.5'
 
const viaNumber = Number(priceText) // 42.5
const viaParseFloat = Number.parseFloat(priceText) // 42.5
const viaParseInt = Number.parseInt(priceText, 10) // 42  ← truncated
const viaUnaryPlus = +priceText // 42.5

Three of them agree. parseInt stops at the decimal point, because that is its job — it parses an integer and discards the rest of the string.

All four are typed the same way in the standard library:

interface ConversionSignatures {
  Number(value: string): number
  parseInt(string: string, radix?: number): number
  parseFloat(string: string): number
}

Nothing there mentions failure. Keep that in mind for the next three sections.

What each one does with awkward input

The differences only show up on input that is not a clean numeric string — which is exactly the input you get from a form field, a query parameter, or an environment variable.

InputNumber()parseInt(s, 10)parseFloat()+s
'42'42424242
'42.5'42.54242.542.5
'12px'NaN1212NaN
''0NaNNaN0
' '0NaNNaN0
'1e3'1000110001000
'0x1f'310031
null0NaNNaN0

Two rows deserve a second look.

The empty string becomes 0 under Number() and +. An untouched text input, a cleared field, a missing environment variable that you defaulted to '' — all of them quietly turn into zero instead of failing. That is the single most common bug in this area.

'12px' becomes 12 under both parseInt and parseFloat. They parse as far as they can and ignore the rest, which is useful when you are reading CSS values and a trap everywhere else.

null only appears in that table as a runtime note. TypeScript rejects parseInt(null) outright, since the parameter is declared string. Number(null) compiles, because Number takes any.

Why parseInt without a radix is a trap

parseInt takes an optional second argument: the base to parse in. Leave it out and the function guesses from the string.

const hexInput = '0x1f'
 
const guessedRadix = Number.parseInt(hexInput) // 31 — read as hexadecimal
const explicitRadix = Number.parseInt(hexInput, 10) // 0 — stops at the 'x'

A string that arrives from a user, an API, or a URL should never pick its own number base. Pass 10 every time. ESLint's radix rule exists for exactly this and is worth turning on.

Modern JavaScript no longer treats a leading zero as octal, so parseInt('077') is 77 rather than 63. That particular landmine is gone, but the hex case is not.

NaN is typed as number, and that is the real problem

This is the part the MDN-style articles skip. Every conversion above returns number. NaN is a number. So the compiler sees nothing wrong with this:

declare const quantityField: string
 
const quantity = Number(quantityField)
const lineTotal = quantity * 19.99 // number
const invoiceLine = `Total: ${lineTotal.toFixed(2)}` // compiles fine

❌ If quantityField is 'two', quantity is NaN, lineTotal is NaN, and invoiceLine reads Total: NaN. Every step type-checks. The failure travels all the way to the user's screen, and it spreads: any arithmetic touching NaN produces NaN, so one bad field poisons a whole total.

TypeScript cannot help here, because NaN is not a separate type. There is no NonNaNNumber. The only fix is to stop letting a raw conversion escape into the rest of your code.

A safe parse that returns number or null

Wrap the conversion once, check for failure at the boundary, and return a union instead of a bare number:

function parseNumber(input: string): number | null {
  const trimmedInput = input.trim()
  if (trimmedInput === '') return null
 
  const converted = Number(trimmedInput)
  return Number.isNaN(converted) ? null : converted
}

Three things are happening. The trim-and-check kills the '' → 0 case. Number() handles decimals and exponents without the parseInt truncation. Number.isNaN turns the failure into null.

One gap is worth closing while you are here. Number('Infinity') returns Infinity, which is not NaN, so the helper above lets it through. Swapping the last check for Number.isFinite rejects 'Infinity', '-Infinity' and NaN in a single test, which is almost always what you want from a parser reading untrusted input.

✅ Now the caller cannot ignore it:

declare const quantityInput: string
 
const parsedQuantity = parseNumber(quantityInput) // number | null
 
// parsedQuantity * 19.99  ← compiler error: possibly null

That error is the entire point. To get at the number you have to narrow it first, which is what typeof is for:

if (typeof parsedQuantity === 'number') {
  const safeTotal = parsedQuantity * 19.99
  console.log(safeTotal.toFixed(2))
}

Or, when a default is good enough, reach for the null coalescing operator:

declare const pageSizeParam: string
 
const pageSize = parseNumber(pageSizeParam) ?? 25

Use ?? rather than || here. With ||, a legitimate 0 would be thrown away and replaced by the fallback, which is its own small bug.

Number.isNaN, not the global isNaN

There are two isNaN functions and they do different things.

The global isNaN coerces its argument first, so it answers "would this become NaN?" rather than "is this NaN?". Number.isNaN skips the coercion and checks the value itself.

const notANumber = Number('banana')
 
const strictCheck = Number.isNaN(notANumber) // true
const alsoStrict = Number.isNaN(42) // false

In practice TypeScript already pushes you towards the right one: the global isNaN is declared as isNaN(number: number): boolean, so passing it a string is a compile error. Inside a function that has already converted to number, both behave identically — but Number.isNaN says what you mean, and it keeps working if the value's type later widens.

Never compare against NaN directly. notANumber === NaN is always false, because NaN is the one value in JavaScript that is not equal to itself.

Parsing unknown and string | undefined

Real input rarely arrives as a clean string. It arrives as string | undefined from an environment variable, or as unknown from JSON.parse. Both cases are worth one more helper:

function parseUnknownNumber(input: unknown): number | null {
  if (typeof input === 'number') return Number.isNaN(input) ? null : input
  if (typeof input === 'string') return parseNumber(input)
  return null
}

It handles the JSON case where a field is sometimes 42 and sometimes '42', and it rejects null, true, arrays and objects instead of coercing them. Number([]) is 0 and Number(true) is 1; neither is something you want arriving in a price field.

The string | undefined case needs no new helper, just the ?? from earlier:

declare const envPort: string | undefined
 
const serverPort = parseNumber(envPort ?? '') ?? 3000

The empty-string default is safe precisely because parseNumber rejects it. That is the payoff of handling the blank case inside the helper rather than at every call site.

Two cases no conversion function handles

The helper above covers almost everything you will meet. Two inputs defeat it, and both look harmless.

The first is a formatted number. Anything a human typed, or anything a backend serialised for display, may carry a thousands separator:

const formattedAmount = '1,234.50'
 
const naiveAmount = Number(formattedAmount) // NaN
const worseAmount = Number.parseFloat(formattedAmount) // 1  ← stops at the comma

Number() at least fails loudly. parseFloat returns 1, which is far worse: a wrong number that flows on silently. And the separator is locale-dependent — German input writes the same value as '1.234,50', where parseFloat happily returns 1.234. Strip the formatting before parsing, or better, keep the raw unformatted value around and never parse the display string at all.

The second is an integer too large for a JavaScript number. Every number is a 64-bit float, so anything past Number.MAX_SAFE_INTEGER rounds:

const largeIdText = '9007199254740993'
 
const roundedId = Number(largeIdText) // 9007199254740992 — off by one
const isPrecise = Number.isSafeInteger(roundedId) // false

No error, no NaN, just a quietly wrong value. This bites on database IDs, Twitter-style snowflake IDs and anything else that outgrew 32 bits. The fix is to not convert at all: keep the ID as a string, or use BigInt if you need to do arithmetic on it. Number.isSafeInteger is the guard worth adding to any helper that parses identifiers.

Going the other way, and going to the type level

Converting back is the easy direction — String(n), n.toString(), or string interpolation, which is usually the most readable of the three.

Converting at the type level is a different exercise entirely. Since TypeScript 4.8 you can turn a string literal type into a number literal type, with no runtime code at all:

type ToNumberType<S extends string> = S extends `${infer N extends number}` ? N : never
 
type ParsedPort = ToNumberType<'8080'> // 8080
type ParsedJunk = ToNumberType<'8080px'> // never

That infer ... extends clause is doing the parsing, and it is the basis of typed route parameters and type-level arithmetic. If that is what you came for, the String to Number challenge builds the full version, and template literal types covers the groundwork.

What to use

Default to Number(), wrapped in a helper that returns number | null. It handles decimals and exponents, it does not silently truncate, and the wrapper closes the '' → 0 hole.

Reach for Number.parseInt(value, 10) only when you genuinely want integer-prefix parsing — CSS units, version segments — and always pass the radix. Use Number.parseFloat for the same leading-number behaviour with decimals. Skip unary + in shared code; it is terse, but it reads like a typo and behaves exactly like Number() anyway.

The rule underneath all of it: a raw conversion should never leave the function it happens in. Turn the failure into null at the boundary, and the type system will carry it the rest of the way.

Share this article

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript string to number knowledge to the test with these related challenges.

#300String to Number
Hard

Related Concepts

Concepts that build on or relate to typescript string to number.

TypeScript String InterpolationUnion TypesTypeScript typeofNull Coalescing Operator in TypeScriptTemplate Literal Types