# How do you verify TypeScript type narrowing at runtime boundaries?

> Learn how to spot unsafe type assertions (as), truthiness narrowing bugs, and unvalidated JSON payloads at TypeScript runtime boundaries.

- Canonical HTML: [https://unrust.dev/guides/typescript-type-narrowing-and-runtime-boundaries](https://unrust.dev/guides/typescript-type-narrowing-and-runtime-boundaries)
- Markdown representation: [https://unrust.dev/guides/typescript-type-narrowing-and-runtime-boundaries/index.md](https://unrust.dev/guides/typescript-type-narrowing-and-runtime-boundaries/index.md)

Published: 2026-10-22
Published by Unrust. Scoring and editorial method: https://unrust.dev/help#methodology

## Direct answer

To verify TypeScript type narrowing at runtime boundaries, treat every external input (JSON.parse, req.body, URL query params, database JSONB, and environment variables) as unknown until a runtime check verifies its shape, and audit every type guard or conditional check to ensure truthiness (if (val)) does not accidentally exclude valid 0, false, or empty-string values.

## Compile-time types vanish at runtime I/O boundaries

Writing const payload = (await req.json()) as InvoiceRequest silences the TypeScript compiler without running a single runtime check. If a client sends { amount: '50' } as a string instead of a number, payload.amount + 10 evaluates to '5010' at runtime.

Whenever code crosses an I/O seam—HTTP bodies, webhook payloads, localStorage, or message queues—verify that a runtime validator or explicit typeof / Array.isArray guard checks the value before the rest of the module trusts the narrowed type.

- [Try the JavaScript & TypeScript truthiness explorer](https://unrust.dev/tools/javascript-truthiness/index.md)
- [Open the TypeScript explicit-false challenge](https://unrust.dev/guides/typescript-explicit-false-challenge/index.md)


## Watch out for truthiness narrowing on union types

In TypeScript, if (value) narrows string | number | null | undefined by removing null and undefined—but it also skips the branch when value is 0, '', or false.

When reviewing code that handles numeric offsets, prices, retry counts, or boolean flags, check that narrowing uses value !== null && value !== undefined (or value != null) rather than bare truthiness.



## Audit custom user-defined type predicates (x is T)

A function returning val is AdminUser tells the compiler to trust whatever boolean expression the function returns. If the predicate only checks 'role' in val without checking that val.role === 'admin', TypeScript still narrows the type to AdminUser on the true branch.

Read the body of every custom x is T helper with a malicious or malformed object in mind to confirm the runtime check is as strict as the narrowed type claims.



## A TypeScript boundary & narrowing review table

Use this checklist when reviewing TypeScript pull requests that parse external data or narrow union types.

### External data (req.json(), JSON.parse, env vars) is typed using an `as SomeType` assertion

- **Review decision:** Replace bare `as` assertions at I/O boundaries with runtime schema parsing or explicit field guards
- **Evidence to verify:** Malformed or stringified fields are rejected or coerced predictably at runtime instead of corrupting downstream math

### A union type containing number, boolean, or string is narrowed with `if (x)` or `x ? a : b`

- **Review decision:** Trace what happens when x is 0, false, or ''
- **Evidence to verify:** Explicit `x !== undefined` or `x ?? default` is used when 0, false, or '' is a valid domain value

### A helper uses a custom type predicate (`function isValid(x: unknown): x is Target`)

- **Review decision:** Check every required property and literal discriminant of `Target` inside the predicate body
- **Evidence to verify:** An object with `{ role: 'guest' }` or `{ amount: NaN }` cannot pass a predicate that narrows to a privileged or numeric type

## Try the TypeScript review: why does the discount check fail?

Consider this TypeScript helper:
function applyDiscount(priceCents: number, discountPct?: number): number {
  if (!discountPct) {
    return priceCents;
  }
  return Math.round(priceCents * (1 - discountPct / 100));
}
This function works for 0% and 20%, but a developer refactors it to support a price override:
function resolvePrice(baseCents: number, overrideCents?: number): number {
  return overrideCents ? overrideCents : baseCents;
}
What breaks when a caller passes resolvePrice(1500, 0) for a free promotional item?

- It returns 0 as intended because 0 is a valid number in TypeScript.
- Because 0 is falsy at runtime, overrideCents ? overrideCents : baseCents ignores the 0 override and charges 1500 cents.
- The TypeScript compiler refuses to compile a ternary on number | undefined.
- It returns NaN.

**Reasoning:** TypeScript's type narrowing allows if (overrideCents) to narrow number | undefined to number, but at runtime that check also excludes 0. Use overrideCents ?? baseCents or overrideCents !== undefined.

## Practice loop

1. Audit every `as` type assertion in a diff and ask whether the value just crossed an unvalidated runtime I/O boundary.
2. Test every union narrowing check with 0, false, and '' to verify valid boundary values are not dropped.
3. Use the free JavaScript & TypeScript Truthiness Explorer at /tools/javascript-truthiness to compare ||, ??, and explicit undefined checks.

## Common questions

### Does enabling strictNullChecks in tsconfig.json prevent falsy 0 or false bugs?

No. strictNullChecks forces you to handle null and undefined before using a value, but if you handle them with `if (val)` or `val || default`, runtime truthiness will still catch 0, false, and ''.

### Where can I try a hands-on TypeScript boolean-default challenge?

Open the TypeScript explicit-false challenge at /guides/typescript-explicit-false-challenge or take the free TypeScript diagnostic at /diagnostic.

## Limitations

Runtime boundary checks protect data integrity at module seams; they do not replace compile-time strictness flags such as strictNullChecks or noUncheckedIndexedAccess.

## Provenance

Original editorial guide by the Unrust team covering TypeScript type narrowing, type predicates, and runtime boundary validation.

## Sources

- [TypeScript Handbook: Narrowing and Truthiness Guards](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) — Microsoft TypeScript Documentation. Explains how truthiness narrowing interacts with falsy primitives like 0, '', and false.
- [OWASP Input Validation Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html) — OWASP Foundation. Covers why runtime validation is required at trust and serialization boundaries regardless of static source types.

## Related guides

- [Why does a TypeScript default erase an explicit false?](https://unrust.dev/guides/typescript-explicit-false-challenge/index.md): Test a boolean default with false and an omitted field, then use nullish coalescing to preserve an explicit user choice.
- [How do you read code against its contract?](https://unrust.dev/guides/reading-code-against-a-contract/index.md): Learn how to compare implementation with intent, find mismatched assumptions, and catch bugs that clean-looking code can hide.
- [How do you review multi-file diffs from Cursor or Claude Code?](https://unrust.dev/guides/reviewing-cursor-and-claude-code-diffs/index.md): Learn a step-by-step review checklist for auditing multi-file edits generated by agentic coding tools like Cursor, Claude Code, and GitHub Copilot.

Next step: [Take the free diagnostic](https://unrust.dev/diagnostic/index.md)
