From compile-time promise to verified runtime guarantee
TypeScript types disappear completely once your code compiles, yet real data from APIs, forms, and third party libraries does not always match the shape it promised. This article shows how built in narrowing, custom type guard functions, and assertion functions work together to check values reliably at runtime and hand the compiler precise, trustworthy types afterward.
Table of Contents
- 1. Context: Compile-Time Types vs. Runtime Reality
- 2. Built-in Narrowing: typeof, instanceof, in, Array.isArray
- 3. Custom Type Guard Functions with "value is Type"
- 4. Assertion Functions with "asserts value is Type"
- 5. Narrowing Arrays, unknown, Generics, and Discriminated Types
- 6. Where Narrowing Fails: Closures, Index Access, Aliasing
- 7. Helping the Compiler: satisfies, Const Assertions, Control-Flow Limits
- 8. Practical Example: Validating Unknown API Responses at Runtime
- 9. Comparison: Unsafe Casts vs. Safe Narrowing
- 10. Summary
- 11. FAQ
1. Context: Compile-Time Types vs. Runtime Reality
TypeScript types exist exclusively at compile time. The compiler checks every type annotation, then strips it away entirely and emits plain JavaScript with no type information left at all. Inspect a variable in the Node debugger and you will not find a trace of an interface or type, only the bare runtime value. This type erasure is at the heart of a misconception that costs many newcomers dearly: a parameter declared as string is only actually a string at runtime if nobody ever passed something else, say from a JSON response, a form field, or an external library without its own types.
Type guards and narrowing close exactly that gap: they tie a runtime check to a compile-time guarantee, so the compiler is allowed to assume a more precise, narrower type inside a verified block of code. Narrowing is not an optional feature here, it is the mechanism that makes union types, unknown, and optional fields usable at all, without developers constantly reaching for as casts that only fake the actual check.
2. Built-in Narrowing: typeof, instanceof, in, Array.isArray
The TypeScript compiler analyzes a function's control flow and automatically narrows a variable's type once a condition rules it in unambiguously. typeof works reliably for primitives like string, number, boolean, and function, while instanceof applies to classes and built-in objects like Date or Error. The in operator checks whether a property exists on an object, which makes it well suited for unions of several object shapes that share no common discriminant field.
Truthiness checks like if (value) reliably narrow out null and undefined, but they also exclude 0, the empty string, and NaN, which easily causes bugs with numeric values. Array.isArray() is the only robust way to distinguish a single value from an array, since typeof only reports "object" for arrays. Equality checks against literals, such as value === 'ok', narrow literal union types just as precisely as a switch statement over a discriminant field.
// Built-in narrowing: typeof, instanceof, in, Array.isArray, truthiness
type Input = string | number | Date | { code: string } | null;
function describe(value: Input): string {
if (value === null) {
return 'no value';
}
if (typeof value === 'string') {
// narrowed to string
return value.toUpperCase();
}
if (typeof value === 'number') {
// narrowed to number
return value.toFixed(2);
}
if (value instanceof Date) {
// narrowed to Date
return value.toISOString();
}
if ('code' in value) {
// narrowed to { code: string }
return `code:${value.code}`;
}
// exhaustive: TypeScript knows nothing is left here
return 'unreachable';
}
function sumNumbers(values: (number | null)[]): number {
return values
.filter((v): v is number => v !== null) // equality narrowing inside a guard
.reduce((total, v) => total + v, 0);
}
function joinIds(ids: string | string[]): string {
return Array.isArray(ids) ? ids.join(',') : ids;
}
3. Custom Type Guard Functions with "value is Type"
Once the built-in checks stop being enough, say for more complex object shapes or validating unknown values, custom type guard functions come into play. A function becomes a type guard when its return type is not boolean but the special signature value is Type. The function body still has to return a real boolean, but the compiler interprets the result at every call site as a narrowing signal for the checked parameter.
The big advantage over inline checks is reusability: a guard like isCustomer() written once can be used across as many functions as needed without duplicating the check logic, and it narrows reliably inside if conditions, filter() calls, and while loops alike. What matters is that the implementation actually checks what the signature claims: TypeScript never verifies the runtime logic itself, an incorrectly implemented guard produces false type safety that is harder to debug than having no guard at all.
// Custom type guard: "value is Type" tells the compiler how to narrow
interface Customer {
type: 'customer';
email: string;
}
interface Guest {
type: 'guest';
sessionId: string;
}
type Visitor = Customer | Guest;
function isCustomer(visitor: Visitor): visitor is Customer {
return visitor.type === 'customer';
}
function greet(visitor: Visitor): string {
if (isCustomer(visitor)) {
// narrowed to Customer, email is available
return `Welcome back, ${visitor.email}`;
}
// narrowed to Guest by elimination
return `Welcome, guest ${visitor.sessionId}`;
}
// Guard that works on unknown, not just a known union member
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasStringProp(value: unknown, key: string): boolean {
return isRecord(value) && typeof value[key] === 'string';
}
4. Assertion Functions with "asserts value is Type"
Assertion functions solve a different problem than classic type guards: instead of checking a condition and offering two branches (true/false), they throw an exception on violation and let the rest of the function continue in the positive case. The signature asserts value is Type tells the compiler that after calling the function, the passed value may be treated as the given type for the remainder of the enclosing scope, with no if branch required.
The simpler variant asserts condition does not narrow a specific value, it only tells the compiler that the function does not return normally when the condition is false. That is especially useful for generic checks like division by zero or array length validation. In practice, assertion functions often replace guard clauses at the top of a function and produce readable code without nested if blocks, with the caveat that they must throw and cannot return a fallback value.
// Assertion functions: "asserts value is Type" and "asserts condition"
class ValidationError extends Error {}
function assertIsString(value: unknown, fieldName: string): asserts value is string {
if (typeof value !== 'string') {
throw new ValidationError(`${fieldName} must be a string`);
}
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new ValidationError(message);
}
}
function processOrderId(input: unknown): string {
assertIsString(input, 'orderId');
// input is narrowed to string for the rest of the function's scope
return input.trim().toUpperCase();
}
function divide(a: number, b: number): number {
assert(b !== 0, 'divisor must not be zero');
// the runtime guarantee now matches the assumption in the line below
return a / b;
}
5. Narrowing Arrays, unknown, Generics, and Discriminated Types
Narrowing gets trickier once several layers combine: arrays of unknown values, generic type parameters, and discriminated unions with more than two variants. There is no built-in counterpart to Array.isArray that also checks the element type, which is why value.every((item) => typeof item === 'string') combined with a custom type guard is the standard way to reliably narrow unknown[] down to string[].
For generic functions like ApiResult<T>, narrowing via a discriminant field such as status works exactly like it does for concrete types, since TypeScript runs control-flow analysis independently of the generic parameter T. Discriminated unions with many variants benefit especially from a switch statement with a default branch checked against never: if a variant is left unhandled, the compiler flags an error immediately when a new union member is added, instead of silently ignoring the case.
// Narrowing discriminated unions, generics, and unknown arrays
type ApiResult<T> =
| { status: 'ok'; data: T }
| { status: 'error'; message: string };
function unwrap<T>(result: ApiResult<T>): T {
if (result.status === 'error') {
// narrowed to the error branch
throw new Error(result.message);
}
// narrowed to the ok branch, data is T
return result.data;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function firstOrEmpty(value: unknown): string {
if (!isStringArray(value)) {
return '';
}
// value is string[], value[0] is string | undefined until checked
const first = value[0];
return first !== undefined ? first : '';
}
6. Where Narrowing Fails: Closures, Index Access, Aliasing
Narrowing has clear limits, and they regularly produce surprising compiler errors. Inside closures, narrowing information gets lost as soon as TypeScript cannot rule out that the value changed between the check and a later access, for example when a callback runs asynchronously or the checked variable is a let instead of a const. TypeScript also does not narrow after an array index access by default: array[0] always returns the element type, never ElementType | undefined, unless noUncheckedIndexedAccess is enabled in tsconfig.json.
Another trap is aliasing: assigning a checked value to an object property, such as obj.value = checkedValue, loses the narrowing information for obj.value on future accesses, because the compiler does not track object properties as deeply as local variables. In all of these cases, it helps to extract the checked value into its own immutable local variable rather than relying on a check performed once to keep holding true.
7. Helping the Compiler: satisfies, Const Assertions, Control-Flow Limits
Where automatic control-flow analysis reaches its limits, there are targeted tools to help the compiler along. The satisfies operator, available since TypeScript 4.9, checks whether a literal matches a type without overwriting the literal's inferred type the way a cast would. That is especially valuable for configuration objects: const config = {...} satisfies Config keeps the precise literal types of individual fields while still guaranteeing the structure matches Config.
Const assertions with as const prevent TypeScript from widening literals to their broader base type, which matters in particular for discriminated unions and tuples. Where neither satisfies nor const assertions are enough, assertion functions remain the last resort: they let you carry knowledge the compiler cannot derive structurally, such as invariants from business logic, into the type system explicitly and checked, instead of forcing it through an unchecked as Type cast.
8. Practical Example: Validating Unknown API Responses at Runtime
The most common practical use case for type guards is validating data that comes from outside your own type system: API responses, JSON.parse() results, form input, or messages from a web worker. fetch() returns the type any from response.json() by default, which completely undermines actual type safety if the result gets used unchecked. The safe path starts by explicitly typing the result as unknown and only unlocking it through a custom type guard.
A guard like isProduct() checks every expected field individually, including nested arrays, and only returns true once the entire structure matches the expected shape. That check costs a little runtime overhead, but it prevents exactly the class of bugs that would otherwise surface deep in application code as undefined is not a function. For larger projects, it is worth switching to validation libraries like Zod or Valibot, which couple schema definition and type guard generation automatically.
// Practical example: validating an unknown API response at runtime
interface Product {
id: number;
name: string;
price: number;
tags: string[];
}
function isProduct(value: unknown): value is Product {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === 'number' &&
typeof candidate.name === 'string' &&
typeof candidate.price === 'number' &&
Array.isArray(candidate.tags) &&
candidate.tags.every((tag) => typeof tag === 'string')
);
}
async function fetchProduct(url: string): Promise<Product> {
const response = await fetch(url);
const json: unknown = await response.json();
if (!isProduct(json)) {
throw new Error('API response does not match the Product shape');
}
// json is narrowed to Product from here on
return json;
}
9. Comparison: Unsafe Casts vs. Safe Narrowing
The table below compares unsafe casts against their corresponding safe narrowing techniques. The difference is the same in every row: an as Type cast overrides the compiler's type check without guaranteeing anything at runtime, while the safe variant performs a real check and only then does the compiler issue the guarantee.
| Scenario | Unsafe Approach | Safe Approach | Why It's Safer |
|---|---|---|---|
| Checking a value's type | value as string without a check | typeof value === 'string' | The runtime value is actually checked, not just assumed |
| Validating unknown data | response.json() as Product | custom guard isProduct(json) | Every field is checked individually against the expected shape |
| Narrowing after array access | array[0] as string | noUncheckedIndexedAccess + check | Prevents undefined access at runtime |
| Asserting non-null | value! (non-null assertion) | assert(value !== null, ...) | Throws in a controlled way instead of a silent crash later |
| Distinguishing union members | (value as Customer).email | discriminant field + switch with never check | Compiler enforces exhaustiveness when new variants are added |
In practice, this discipline pays off most at the edges of an application, everywhere data enters or leaves your own type system: API boundaries, forms, storage APIs, and messages between threads or windows. Inside a purely internal, consistently typed code path, narrowing is usually correct automatically, without needing extra guards.
Mironsoft
TypeScript code review, runtime validation for APIs, and type-safe frontend architecture
Ready to make your TypeScript type-safe in production?
We review your TypeScript codebase for unsafe casts, missing runtime narrowing, and gaps between API data and type definitions, and implement robust type guards and validation strategies for production-ready frontend and Node applications.
TypeScript Code Review
Systematically uncover unsafe casts, missing narrowing, and type gaps
Runtime Validation for APIs
Type guards and schema validation for unknown data from external sources
Type-Safe Frontend Architecture
Robust type models for Hyvä frontends, headless integrations, and build tools
10. Summary
Type guards and narrowing are the bridge between TypeScript's compile-time type system and the actual runtime reality of JavaScript. Built-in narrowing via typeof, instanceof, in, and Array.isArray covers most everyday cases, custom type guard functions with the value is Type signature extend that to complex and unknown data shapes, and assertion functions with asserts value is Type replace guard clauses with checked assumptions for the rest of the function body.
The limits of automatic narrowing, such as inside closures, after array index access, or with aliasing, can be bridged with targeted tools like satisfies, const assertions, and explicit assertion functions. Applying these techniques consistently at the edges of your application, everywhere external data enters the type system, buys real runtime type safety instead of just an illusion of it at compile time.
Type Guards and Narrowing - The Essentials at a Glance
Built-in narrowing
typeof/instanceof/in/Array.isArray cover most cases without extra code.
Custom guards
value is Type makes reusable checks type-safe and narrowable.
Assertion functions
asserts value is Type replaces guard clauses with checked assumptions.
Know the limits
Closures, index access, and aliasing break narrowing, satisfies and assertions help.