TypeScript String to Number
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.5Three 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.
| Input | Number() | parseInt(s, 10) | parseFloat() | +s |
|---|---|---|---|---|
'42' | 42 | 42 | 42 | 42 |
'42.5' | 42.5 | 42 | 42.5 | 42.5 |
'12px' | NaN | 12 | 12 | NaN |
'' | 0 | NaN | NaN | 0 |
' ' | 0 | NaN | NaN | 0 |
'1e3' | 1000 | 1 | 1000 | 1000 |
'0x1f' | 31 | 0 | 0 | 31 |
null | 0 | NaN | NaN | 0 |
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 nullThat 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) ?? 25Use ?? 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) // falseIn 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 ?? '') ?? 3000The 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 commaNumber() 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) // falseNo 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'> // neverThat 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.
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