Developer Guide

How to Convert a String to Number in TypeScript: 2026 Fix

Fix silent NaN errors when converting strings to numbers in TypeScript. Learn explicit parsing, type narrowing, and runtime validation.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
8 min read

TL;DR#

If you’re seeing NaN or runtime type errors when converting a string to a number in TypeScript, the issue is usually implicit coercion or missing runtime validation. Fix it by using explicit parsing (Number(), parseInt(), unary +) and narrowing the type before use.

If that doesn’t work, scroll to Verify the fix — it also covers typeof still returning 'string' after an as number cast, and Number() returning NaN on hex or scientific notation.

What you'll see#

You’ll see one of these in your editor or terminal:

plaintext
Type 'string' is not assignable to type 'number'.ts(2322)

or at runtime:

plaintext
console.log(Number("abc")) // NaN

or in logs:

plaintext
Expected number, got string: "42"

It happens when you assign a string value (e.g., from an API response, form input, or environment variable) to a variable typed as number, or when you pass a string to a function expecting a number parameter — especially in Next.js API routes, Supabase edge functions, or n8n workflows where data is untyped by default.

The behavior is the same across Node.js, browsers, and Deno — because the issue isn’t environment-specific; it’s in how TypeScript handles type narrowing and coercion.

Common failure modes#

The most frequent failure is assuming TypeScript will infer or cast a string to a number automatically. For example:

ts
// ❌ Common mistake: assuming implicit conversion
const input = "42";
const value: number = input; // Type error: 'string' is not assignable to 'number'

Another is relying on unsafe type assertions:

ts
// ❌ Unsafe: a direct `input as number` is rejected (TS2352),
// so people silence it with a double assertion — which still compiles
const input: string = "42";
const value = input as unknown as number; // typeof value === 'string' at runtime
console.log(value + 1); // "421" — string concatenation!

A third is using parseFloat without validation:

ts
// ❌ Silent failure: no error, but NaN propagates
const input = "abc";
const value = parseFloat(input); // NaN
console.log(value === 0); // false — NaN ≠ 0

All three stem from the same root: TypeScript’s type annotations are compile-time only — the TypeScript handbook is explicit that types are erased before the code runs. At runtime, "42" is still a string, and JavaScript’s coercion rules apply.

Diagnosis#

TypeScript’s typeof operator returns 'string' for any value that is a JavaScript string — even if you declared it as number. This is because:

  1. TypeScript compiles to JavaScript, and the type annotations are removed in the process: at runtime only the JavaScript value exists, and typeof reports what that value really is.
  2. Type assertions (as unknown as number) are erased too — they don’t change runtime behavior. A direct "42" as number does not even compile: TypeScript 5.9 stops with TS2352, "Conversion of type 'string' to type 'number' may be a mistake".
  3. Functions like parseInt, Number, and unary + are the only ways to convert strings to numbers — and they require explicit invocation.

The relevant code path is:

ts
// src/lib/parseNumber.ts
export function parseInput(input: string): number {
  // ❌ This looks safe but isn’t — input is still a string
  const num = input as unknown as number;
  return num + 10; // Runtime: "42" + 10 = "4210"
}
 
// ✅ Correct: explicit parsing + validation
export function parseInputSafe(input: string): number {
  const num = Number(input);
  if (Number.isNaN(num)) {
    throw new Error(`Invalid number string: "${input}"`);
  }
  return num;
}

The root cause is that TypeScript’s static type system cannot guarantee runtime type safety — it only prevents compile-time mismatches. If you accept untyped data (e.g., from req.body, process.env, or fetch), you must validate it before use.

The fix#

Use explicit parsing with runtime validation. Here is a pattern that covers the common inputs:

ts
// src/lib/convert.ts
export function toNumber(value: unknown): number {
  if (typeof value === 'number') {
    return value;
  }
 
  if (typeof value === 'string') {
    const trimmed = value.trim();
    if (trimmed === '') {
      throw new Error('Empty string is not a valid number');
    }
    const num = Number(trimmed);
    if (Number.isNaN(num)) {
      throw new Error(`Cannot convert "${value}" to number`);
    }
    return num;
  }
 
  throw new Error(`Cannot convert ${typeof value} to number`);
}
 
// Usage
const input = "  42  ";
const result = toNumber(input); // 42

That single change addresses the cause because it enforces type safety at runtime — not just at compile time — and handles edge cases like whitespace, empty strings, and invalid formats.

Step by step#

  1. Create a toNumber utility in src/lib/convert.ts.
  2. Replace all as number assertions with toNumber(value).
  3. Wrap API responses or form inputs with toNumber before passing to numeric operations.
  4. Add tests for "0x1F", "+3.14", " 1e3 ", and "abc".

Verify the fix#

Run this test in your REPL or Jest suite:

ts
// src/lib/convert.test.ts
import { describe, it, expect } from 'vitest';
import { toNumber } from './convert';
 
describe('toNumber', () => {
  it('converts valid numeric strings', () => {
    expect(toNumber('42')).toBe(42);
    expect(toNumber('  42  ')).toBe(42);
    expect(toNumber('+3.14')).toBe(3.14);
    expect(toNumber('1e3')).toBe(1000);
    expect(toNumber('0xFF')).toBe(255);
  });
 
  it('throws on invalid input', () => {
    expect(() => toNumber('abc')).toThrow('Cannot convert "abc" to number');
    expect(() => toNumber('')).toThrow('Empty string is not a valid number');
    expect(() => toNumber(null)).toThrow('Cannot convert object to number');
  });
 
  it('preserves number inputs', () => {
    expect(toNumber(42)).toBe(42);
    expect(toNumber(3.14)).toBe(3.14);
  });
});

You should see all tests pass, and no NaN or type errors in your editor.

If you’re still seeing the error, two common variants exist:

Variant A — typeof returns 'string' even after as number#

This happens when you use as number to silence TypeScript but the value remains a string at runtime. The fix is to replace as number with Number(value) and add a runtime check:

ts
// ❌ Before
const value = input as unknown as number;
if (typeof value === 'number') { ... } // Never true at runtime — value is still a string
 
// ✅ After
const num = Number(input);
if (typeof num === 'number' && !Number.isNaN(num)) {
  // Safe to use
}

Variant B — parseInt() returns 0 for hex input#

Number("0xFF") returns 255 and Number("1e3") returns 1000, but parseInt("0xFF", 10) returns 0: with radix 10 it stops at the x. Without a radix, parseInt("0xFF") does read the 0x prefix and returns 255. If you want hex handling to be explicit, strip the prefix and pass radix 16:

ts
// ✅ Hex support
function toNumberHex(value: string): number {
  if (value.startsWith('0x') || value.startsWith('0X')) {
    const num = parseInt(value.slice(2), 16);
    if (Number.isNaN(num)) {
      throw new Error(`Invalid hex: "${value}"`);
    }
    return num;
  }
  return toNumber(value);
}

Why this happens (and how to avoid it next time)#

TypeScript’s type system is designed for static analysis — it catches mismatches before runtime but doesn’t enforce type safety at runtime. This is intentional: JavaScript’s dynamic nature allows flexibility, but it means you must validate external data.

To prevent regressions, adopt two habits:

  1. Always narrow types before conversion. Use typeof value === 'string' guards before parsing.
  2. Add runtime validation in API routes. In Next.js, validate req.body fields before passing to business logic:
ts
// pages/api/submit.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { toNumber } from '@/lib/convert';
 
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const count = toNumber(req.body.count); // Safe, throws on invalid input
    res.json({ count });
  } catch (err) {
    res.status(400).json({ error: 'Invalid count' });
  }
}

The same pattern is covered in more depth in the Complete Type Safety Guide for Next.js and Supabase with TypeScript.

FAQ#

Q: Does this work with const strings?#

A: Yes — but only if the string is known at compile time. For example:

ts
const x: string = "42";
const y: number = Number(x); // ✅ Works — but still requires Number()

TypeScript won’t infer x as "42" unless you use a literal type (const x = "42" as const), but even then, Number(x) is required — TypeScript won’t auto-convert.

Q: What is the difference between Number("42px") and parseInt("42px")?#

A: Both ignore surrounding whitespace (Number(" 42 ") and parseInt(" 42 ") both return 42). The difference is trailing text: parseInt() reads digits until the first character it cannot parse and returns 42 for "42px", while Number() requires the whole string to be numeric and returns NaN. Use Number() when partial input should be rejected.

Q: Can I use the unary + operator?#

A: Yes — +"42" is valid and fast, but it’s less explicit and harder to lint. Number() reads more clearly, and both return 0 for an empty string (+"" and Number("")), so guard empty input either way.

Q: What about BigInt or BigInt64Array?#

A: For values > Number.MAX_SAFE_INTEGER, use BigInt(value) — but note BigInt("42") requires a string, and Number() rounds to the nearest representable double, so Number("9007199254740993") returns 9007199254740992. BigInt patterns are covered in TypeScript Migration Guide 2026.

Further reading: TypeScript ! Operator: Fix Object Is Possibly Undefined.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.