TypeScript narrowing is where the language starts to feel practical rather than theoretical. You are often handed values that could be one of several shapes: an API response, a DOM event target, a function argument, a caught error, or parsed JSON. Narrowing is the process of proving, branch by branch, what a value really is before you use it. This guide is a durable reference for the narrowing tools you will use most often: typeof, in, instanceof, equality checks, discriminated unions, and custom type guards. It also explains what to keep an eye on as your codebase grows, so you can revisit and tighten your runtime checks over time instead of letting them drift into brittle, hard-to-debug branches.
Overview
This article gives you a practical map of TypeScript narrowing and when each strategy fits best. The short version is simple: TypeScript can only narrow safely when your runtime code gives it enough evidence.
Consider a common union:
type Input = string | number;
function format(input: Input) {
if (typeof input === "string") {
return input.toUpperCase();
}
return input.toFixed(2);
}Inside the first branch, TypeScript knows input is a string. In the second branch, it knows the only remaining option is number. That is narrowing: taking a broad type and reducing it to a more specific one based on a check that happens at runtime.
The core tools are:
typeoffor primitives like string, number, boolean, bigint, symbol, undefined, and functioninfor checking whether a property exists on an objectinstanceoffor values created from classes or constructor functions- Equality checks such as
===and!==for literals and shared values - Discriminated unions for application-defined object variants
- Custom type guard functions when the built-in checks are not enough
A useful mental model: TypeScript narrowing is not about making the compiler happy with clever syntax. It is about writing branching logic that reflects how values actually arrive at runtime. When those two line up, your code becomes easier to read, safer to refactor, and less likely to fail in edge cases.
If you want a stronger foundation for unions, utility types, and syntax around these examples, keep a companion reference such as the TypeScript Cheat Sheet: Syntax, Utility Types, and Everyday Patterns nearby. Narrowing tends to make more sense once you can quickly recognize the underlying type shapes.
What to track
If you want your narrowing patterns to stay healthy over time, track the places where runtime uncertainty enters your codebase. These are the areas where guards matter most and where weak checks usually accumulate.
1. Union-heavy function boundaries
Look for functions that accept broad inputs:
type UserId = string | number;
function loadUser(id: UserId) {
if (typeof id === "string") {
return { lookup: "username", value: id };
}
return { lookup: "numeric-id", value: id };
}These boundaries are good candidates for explicit narrowing because they define how callers are allowed to use your API. If a function grows from two variants to five, revisit whether the signature is still clear or whether it should be split into smaller functions.
2. Object unions that should become discriminated unions
One of the most reliable narrowing patterns in TypeScript is the discriminated union: every variant carries a shared field with a literal value.
type ApiState =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function render(state: ApiState) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.join(", ");
case "error":
return state.message;
}
}Track object unions that do not have a clear discriminant yet. If you find yourself repeatedly writing checks like "data" in state and "message" in state, that may be a sign the model should be refactored to use a dedicated status or kind field instead.
3. Property existence checks using in
The in operator is useful when narrowing object unions by structure:
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow();
} else {
animal.bark();
}
}Track where this pattern appears repeatedly. It is effective, but frequent use can signal one of two things:
- The structural model is working well and does not need a class hierarchy
- The model is under-specified and would be easier to maintain as a discriminated union
Also remember that in checks property existence, not property quality. A property may exist but still be the wrong type or hold an invalid value.
4. Class-based checks using instanceof
instanceof is appropriate when values truly come from class instances:
class HttpError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
function handleError(error: Error | HttpError) {
if (error instanceof HttpError) {
return `${error.statusCode}: ${error.message}`;
}
return error.message;
}Track whether you are using instanceof on values that may cross execution boundaries, be deserialized from JSON, or come from third-party libraries with uncertain prototypes. In those cases, property-based checks or schema validation may be more reliable than class identity.
5. Primitive checks using typeof
typeof remains the simplest and safest narrow for primitives:
function serialize(value: string | number | boolean) {
if (typeof value === "string") return value;
if (typeof value === "number") return value.toString();
return value ? "true" : "false";
}Track where primitive unions begin to blur into object handling. Developers sometimes overextend typeof checks and forget its limits:
typeof nullis"object"- Arrays also report as
"object" - Most non-primitive shapes need additional checks
That is a good moment to switch from a quick primitive guard to a more deliberate object guard.
6. Custom type guards and assertion functions
Custom guards become necessary when the runtime check is more specific than TypeScript can infer on its own.
type Product = {
id: string;
price: number;
};
function isProduct(value: unknown): value is Product {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"price" in value &&
typeof (value as { id: unknown }).id === "string" &&
typeof (value as { price: unknown }).price === "number"
);
}Track these functions carefully because they encode your runtime trust model. If a custom guard is too loose, the rest of the codebase inherits that false confidence. If it is too strict, valid data gets rejected and branching logic becomes noisy.
As your code grows, custom guards often deserve the same care as utility libraries: tests, naming conventions, and central placement. They are especially important in API typing, form handling, and data parsing.
7. Narrowing around unknown and external data
Values from JSON.parse, request bodies, browser storage, and third-party SDKs should usually start as unknown, not any. Track whether your external inputs are being narrowed intentionally or silently bypassing checks.
function parseSettings(raw: string) {
const value: unknown = JSON.parse(raw);
if (
typeof value === "object" &&
value !== null &&
"theme" in value
) {
return value;
}
throw new Error("Invalid settings");
}If your application does a lot of runtime validation, a dedicated validation library may eventually become the better long-term fit. Even then, understanding manual narrowing helps you read and trust the generated types and guards.
For related compiler behavior and common mistakes that show up when narrowing is incomplete, see TypeScript Error Guide: Common Compiler Errors and How to Fix Them.
Cadence and checkpoints
You do not need to audit narrowing every week. A light but recurring review is usually enough. The goal is to catch drift: places where the types, the runtime checks, and the real data are no longer aligned.
Monthly checkpoints for active codebases
If your team ships frequently, review these on a monthly basis:
- New union types added in feature work
- New custom type guard functions
- Repeated
asassertions that may be hiding missing narrowing - Branches that depend on optional properties instead of a stable discriminant
- Error handling paths that accept
unknownor mixed error shapes
A good monthly question is: “Where did we accept uncertainty this month, and did we narrow it in a way future readers will trust?”
Quarterly checkpoints for architecture and model quality
Every quarter, step back and review patterns rather than individual lines:
- Should structural unions become discriminated unions?
- Should repeated object checks move into shared guards?
- Are any class-based checks fragile across package or runtime boundaries?
- Are parsed API payloads being validated consistently?
- Are there areas where generics and narrowing should work together more clearly?
This is often where you discover that the issue is not the guard itself, but the type design upstream. If your unions are awkward, your narrowing code will be awkward too. The companion guide on Generics in TypeScript: Practical Patterns for Functions, APIs, and Components can help when broad reusable APIs start interacting with more precise branching logic.
Code review checkpoints
Some narrowing concerns are best caught continuously during review:
- Does the runtime check actually prove what the code claims?
- Is
instanceofbeing used only where class identity is real and stable? - Would a discriminated union make the branch clearer?
- Does a custom guard validate enough fields to be trustworthy?
- Is
anybypassing the whole narrowing story?
Reviewers should be especially alert when a branch uses a value immediately after a weak check. A branch that “works in testing” can still be underspecified from a type safety perspective.
How to interpret changes
When your narrowing patterns change, the important question is not whether there are more guards or fewer guards. The question is whether confidence is moving closer to the data boundary and whether the resulting code is easier to reason about.
If you see more custom guards
This can be a healthy sign. It often means the team is replacing scattered inline checks with named, reusable runtime contracts. That is usually an improvement, especially for request parsing and external integrations.
It can also be a warning sign if every custom guard is slightly different or untested. In that case, centralize the patterns and decide what “valid enough” means for each shared shape.
If you see many in checks on the same union
This often means the type model wants a discriminant. Repeated property probing is workable, but a kind or status field usually produces clearer branches, better autocomplete, and safer exhaustiveness checks.
If you see frequent as assertions
Treat that as missing evidence. Assertions are sometimes necessary, but repeated assertions often mean the runtime check is incomplete or the type design does not match the real data flow.
If instanceof checks become flaky
That usually points to runtime boundaries: deserialized objects, iframe or worker contexts, cross-package duplication, or values built by plain objects rather than constructors. Shift toward structural checks when prototype identity is not guaranteed.
If narrowing logic becomes verbose
Verbosity is not always bad. A careful guard around untrusted input is better than a short, unsafe branch. But if application code is filling up with defensive checks, move that work to a boundary layer. Parse and validate once, then pass strongly typed values deeper into the system.
This is also where configuration matters. Strict compiler settings make narrowing much more valuable because they force unclear branches into the open. If you have not reviewed your compiler options recently, tsconfig.json Best Practices: Recommended Settings for Apps, Libraries, and Monorepos is worth revisiting alongside this topic.
When to revisit
Come back to your narrowing strategy whenever your code starts dealing with more uncertainty than it used to. In practice, that usually happens during specific moments:
- You introduce a new API integration or SDK
- You migrate JavaScript modules to TypeScript and replace loose runtime assumptions
- You add new variants to an existing union type
- You move from simple objects to class instances, or back again
- You notice type assertions spreading through event handlers, reducers, or service layers
- You start handling more untrusted input such as form data, query params, or parsed JSON
A practical revisit workflow looks like this:
- Identify one boundary where uncertain data enters the system.
- Change the input type to
unknownif it is currently over-trusted. - Add the smallest runtime checks that truly prove the required shape.
- Extract a named custom guard if the same logic appears twice.
- Refactor repeated structural checks into a discriminated union when the domain allows it.
- Remove unnecessary assertions once the compiler can follow the proof.
If you want a compact mental checklist, use this one:
- Use
typeoffor primitives. - Use
infor structural object checks. - Use
instanceoffor real class instances. - Prefer discriminated unions for domain variants you control.
- Use custom type guards at trust boundaries.
- Treat repeated
asas a signal to revisit the model.
The best narrowing code is not the most clever. It is the code that a teammate can revisit in three months, read in one pass, and trust immediately. That is why this topic rewards periodic review: every new endpoint, union variant, or validation path creates another chance either to sharpen your type safety or to let ambiguity spread. Revisit narrowing on a monthly or quarterly cadence, especially around boundaries and shared utilities, and your TypeScript code will stay much easier to evolve.