DEV Community

Cover image for How to Convert a String to Number in TypeScript: 2026 Fix
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

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

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.

  • Symptom: NaN, Type 'string' is not assignable to type 'number', or silent failures when assigning string values to number variables.
  • Root cause: TypeScript’s type system doesn’t enforce runtime type safety — implicit coercion or unsafe type assertions bypass compile-time checks.
  • Fix: Use explicit parsing (Number(), parseInt(), unary +) combined with runtime type guards (typeof checks).
  • Verification: Assert type safety at runtime with typeof value === 'number' and test with edge cases like "3.14", "0x1F", and " 42 ".

What you'll see

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

Type 'string' is not assignable to type 'number'.ts(2322)
Enter fullscreen mode Exit fullscreen mode

or at runtime:

console.log(Number("abc")) // NaN
Enter fullscreen mode Exit fullscreen mode

or in logs:

Expected number, got string: "42"
Enter fullscreen mode Exit fullscreen mode

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:

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

Another is relying on unsafe type assertions:

// ❌ Unsafe: bypasses type checking but fails at runtime
const input = "42";
const value = input as number; // Compiles, but typeof value === 'string'
console.log(value + 1); // "421" — string concatenation!
Enter fullscreen mode Exit fullscreen mode

A third is using parseFloat without validation:

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

All three stem from the same root: TypeScript’s type annotations are compile-time only. 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 JavaScript has no number vs string distinction at runtime beyond typeof.
  2. Type assertions (as number) are erased — they don’t change runtime behavior.
  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:

// 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 number;
  return num + 10; // Runtime: "42" + 10 = "421"
}

// ✅ 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;
}
Enter fullscreen mode Exit fullscreen mode

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’s the pattern I use in production:

// 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
Enter fullscreen mode Exit fullscreen mode

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:

// 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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

// ❌ Before
const value = input as number;
if (typeof value === 'number') { ... } // Always true — but value is still string!

// ✅ After
const num = Number(input);
if (typeof num === 'number' && !Number.isNaN(num)) {
  // Safe to use
}
Enter fullscreen mode Exit fullscreen mode

Variant B — Number() returns NaN for hex or scientific notation

Number("0xFF") and Number("1e3") work — but parseInt("0xFF", 10) does not. If you need hex support, use parseInt(value, 16) with explicit base:

// ✅ 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);
}
Enter fullscreen mode Exit fullscreen mode

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:
// 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' });
  }
}
Enter fullscreen mode Exit fullscreen mode

I cover this pattern in detail in Complete Type Safety Guide for Next.js and Supabase with TypeScript, where we enforce runtime validation at the edge with Supabase Functions.

FAQ

Q: Does this work with const strings?

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

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

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: Why does Number(" 42 ") work but parseInt(" 42 ") sometimes doesn’t?

A: Number() trims whitespace automatically; parseInt() does not — it stops parsing at the first non-digit unless the string starts with whitespace. Always use Number(value.trim()) for consistency.

Q: Can I use the unary + operator?

A: Yes — +"42" is valid and fast, but it’s less explicit and harder to lint. I prefer Number() for readability and to avoid confusion with +"" (which returns 0).

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() will truncate. I cover BigInt patterns in TypeScript Migration Guide 2026.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)