On this page
Type narrowing connects runtime evidence with compile-time guarantees. A variable can begin as unknown or as a union of several possibilities, then become more specific after a check. Good narrowing makes invalid operations difficult to express. Bad narrowing merely persuades the compiler to trust a claim that the runtime never established.
Begin with uncertain data, not an assertion
Use the JSON to TypeScript tool to sketch interfaces from sanitized response examples. Compare multiple payloads and identify optional properties, nullable fields, and literal status values. A generated interface is a proposed contract, not proof that the next response satisfies it.
Assign external values to unknown until a runtime check establishes their structure. A type assertion does not inspect an object, convert a number, or add a missing field. It only changes what the compiler believes. That difference matters whenever data comes from storage, a network response, a message, or a third-party script.
Define the smallest useful contract before writing guards. If a component only needs an identifier and display name, validate those fields rather than pretending to have validated a full account record. Broader language examples in the JavaScript and TypeScript category can help teams agree on this boundary-oriented approach.
Let built-in checks do the easy work
TypeScript follows branches and early returns to refine types. A typeof check can distinguish strings from numbers; instanceof can recognize appropriate runtime instances; equality checks can eliminate null and undefined. These checks often make a custom predicate unnecessary. Read the official narrowing handbook for the complete control-flow model.
function formatValue(value: string | number | null): string {
if (value === null) return "Not provided";
if (typeof value === "number") return value.toFixed(2);
return value.trim();
}The final branch is a string because earlier returns eliminated the other possibilities. This control-flow analysis keeps code readable when each branch establishes one clear fact. Avoid unnecessary assertions after a check that already proves the type; they add noise and can hide later changes to the surrounding logic.
Watch the familiar traps. typeof null is object, so an object check must also exclude null. Arrays are objects too, which may or may not fit your intended contract. A truthiness check removes zero and empty strings along with absent values. Use an explicit nullish comparison when those values are legitimate input.
The in operator proves a property is present somewhere on an object's prototype chain, but does not prove the property's value has the right type. Likewise, instanceof can fail across realms, such as objects created in another window. Choose checks according to the data's origin, not only the type you want to obtain.
Write guards whose promises match their checks
A type predicate declares that a boolean result establishes a type. TypeScript trusts that declaration; it does not formally prove that your implementation checks every required field. Treat a guard as a small validator with tests, not as a convenient alternative spelling for an assertion.
type AccountSummary = {
id: string;
name: string;
active: boolean;
};
function isAccountSummary(value: unknown): value is AccountSummary {
if (typeof value !== "object" || value === null) return false;
return (
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"active" in value && typeof value.active === "boolean"
);
}This guard accepts additional properties because the contract is structural. It also accepts empty strings; if identifiers must be nonempty, that is a further business rule to enforce deliberately. Distinguish structural validation from domain validation so callers understand what the guard actually guarantees.
A guard used by filter has an important negative side too. If you claim a predicate identifies every number but also reject negative numbers, the false branch does not mean the value is not a number. Prefer a boolean-only eligibility check when the condition selects a business subset rather than a true type partition.
For large nested payloads, a schema validator can reduce repetitive checks and provide useful error paths. Handwritten guards remain valuable for small contracts, but each new field adds maintenance work. The TypeScript functions guide provides supporting detail on function signatures and the promises they expose to callers.
Model related cases as discriminated unions
If several states have different required data, give them a shared literal property. A discriminated union lets the compiler connect a specific tag with the fields available in that case. This is safer than a single object with many optional properties whose valid combinations live only in comments.
type LoadState =
| { status: "loading" }
| { status: "success"; accounts: AccountSummary[] }
| { status: "error"; message: string };
function assertNever(value: never): never {
throw new Error("Unhandled load state");
}
function describe(state: LoadState): string {
switch (state.status) {
case "loading": return "Loading";
case "success": return String(state.accounts.length);
case "error": return state.message;
default: return assertNever(state);
}
}The never parameter creates an exhaustiveness check. Adding a new union member produces a compiler error until the switch handles it. That protects internal modeled states; it does not validate an arbitrary server response before that response enters the union. Keep the runtime boundary check even when internal branching is exhaustive.
Prefer literal tags with stable meanings over incidental property checks when you control the model. A property such as message might appear in several future variants, while status is deliberately designed to distinguish them. Good data modeling reduces the amount of clever narrowing required throughout the application.
Preserve evidence and test negative cases
Narrowing can become less useful when code mutates the checked value or uses it much later in a callback. Capture a validated primitive or object reference in a local constant when that makes the guarantee clearer. Remember that a constant binding does not freeze an object's properties or stop another reference from changing them.
Use assertion functions when failure should throw rather than return false. Their assertion signatures are also trusted contracts, so they need the same rigor as predicates. The TypeScript 3.7 documentation introduces this pattern and explains how successful return affects subsequent analysis.
Test null, arrays, missing fields, wrong primitive types, and plausible malformed records. Add a positive example with extra fields to document whether structural acceptance is intentional. Keep guard tests near your development workflow, and update them whenever the declared contract changes. A compiling predicate can still be a broken validator.
Key takeaways
- Keep untrusted data unknown until runtime checks establish its shape.
- Prefer built-in narrowing and early returns for simple cases.
- Make custom predicates validate every field they promise.
- Use discriminated unions and never for exhaustive internal branching.
- Test rejected inputs as carefully as accepted examples.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor