//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Narrow TypeScript types at runtime using type guards, assertion functions, the in operator, and the satisfies keyword. Write code that gives TypeScript enough information to infer precise types in each branch.
// typeof narrowing
function formatValue(value: string | number | boolean) {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows: string
}
if (typeof value === "number") {
return value.toFixed(2); // TypeScript knows: number
}
return value ? "Yes" : "No"; // TypeScript knows: boolean
}
// Custom type guard with "is" predicate
type User = { kind: "user"; name: string; email: string };
type Admin = { kind: "admin"; name: string; permissions: string[] };
type Account = User | Admin;
function isAdmin(account: Account): account is Admin {
return account.kind === "admin";
}
function AccountBadge({ account }: { account: Account }) {
if (isAdmin(account)) {
return <span>Admin: {account.permissions.length} permissions</span>;
}
return <span>User: {account.email}</span>;
}// "in" operator narrowing
function renderAccount(account: Account) {
if ("permissions" in account) {
// TypeScript narrows to Admin
return <div>{account.permissions.join(", ")}</div>;
}
// TypeScript narrows to User
return <div>{account.email}</div>;
}// satisfies keyword - validate without widening
const ROUTES = {
home: "/",
about: "/about",
contact: "/contact",
} satisfies Record<string, string>;
// Type is preserved as { home: "/"; about: "/about"; contact: "/contact" }
// Not widened to Record<string, string>
type RouteKey = keyof typeof ROUTES; // "home" | "about" | "contact"string, number, boolean, symbol, bigint, undefined, function, and object.param is Type return type annotation. When the function returns true, TypeScript narrows the parameter to Type in the calling scope.in operator narrows based on property existence. "email" in account narrows to the union member(s) that have an email property.instanceof narrows class instances: if (error instanceof TypeError) narrows to TypeError.satisfies validates that an expression conforms to a type without changing its inferred type. This preserves literal types and specific shapes while ensuring correctness.asserts param is Type. They throw if the condition is false and narrow the type for all subsequent code (not just the if block).Assertion function:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function processInput(input: unknown) {
assertIsString(input);
// TypeScript knows input is string from here on
console.log(input.toUpperCase());
}Narrowing with Array.isArray:
function renderItems(data: string | string[]) {
if (Array.isArray(data)) {
return <ul>{data.map((item) => <li key={item}>{item}</li>)}</ul>;
}
return <p>{data}</p>;
}Discriminated union narrowing (switch):
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderState<T>(state: AsyncState<T>, renderData: (data: T) => React.ReactNode) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return renderData(state.data); // Narrowed: data exists
case "error":
return <ErrorMessage error={state.error} />; // Narrowed: error exists
}
}satisfies with config objects:
type ColorConfig = Record<string, { bg: string; text: string }>;
const THEME = {
primary: { bg: "#3b82f6", text: "#ffffff" },
danger: { bg: "#ef4444", text: "#ffffff" },
success: { bg: "#22c55e", text: "#ffffff" },
} satisfies ColorConfig;
// THEME.primary is fully typed with literal values
// THEME.nonExistent would errorif and else branches. In the else of isAdmin(account), TypeScript knows account is User.satisfies was added in TypeScript 4.9. It is especially valuable for configuration objects, route maps, and constant definitions.if block. This makes them powerful for early validation at the top of a function.if (x), if (x != null)) narrow out null and undefined but also narrow out falsy values like 0 and "". Use != null for precise null/undefined narrowing.is predicate. If you write a buggy guard, TypeScript will be wrong about the narrowed type.typeof null === "object" is a JavaScript quirk. Use value !== null && typeof value === "object" for object checks.const { status } = state; if (status === "success") { state.data } does not narrow state to the success variant. Use state.status directly.| Approach | Pros | Cons |
|---|---|---|
Custom type guard (is) | Reusable, readable, explicit | Guard correctness is your responsibility |
in operator | No helper function needed | Only checks property existence, not value type |
instanceof | Built into JavaScript | Only works with classes, not interfaces |
satisfies | Validates without widening | Does not create a reusable type |
Assertion function (asserts) | Narrows all subsequent code | Must throw, cannot return false |
Zod .parse() | Runtime + compile-time safety | External dependency |
if, switch, typeof, in, etc.).typeof narrows to: string, number, boolean, symbol, bigint, undefined, function, and object.if (typeof value === "string") block, TypeScript knows value is string.function isAdmin(account: Account): account is Admin {
return account.kind === "admin";
}param is Type return type annotation.true, TypeScript narrows the parameter to the specified type.if block where it is checked.if).false.satisfies validates that an expression conforms to a type without changing its inferred type.const x: T = ...) widens the type to T.satisfies preserves literal types and specific shapes while ensuring correctness.typeof value === "object" alone does not exclude null.value !== null && typeof value === "object" for safe object checks.if ("permissions" in account) {
// TypeScript narrows to the union member that has "permissions"
account.permissions; // OK
}const { status } = state; if (status === "success") { state.data } fails because TypeScript loses the connection between status and state.state.status directly in the check to maintain narrowing.// With satisfies: preserves literal types
const ROUTES = {
home: "/",
about: "/about",
} satisfies Record<string, string>;
// Type: { home: "/"; about: "/about" }
// With annotation: widens to Record<string, string>
const ROUTES2: Record<string, string> = { home: "/", about: "/about" };satisfies when you want validation AND preserved literal types.else branch of if (isAdmin(account)), TypeScript knows account is User (the other union member).typeof, in, instanceof, and custom guards.function renderItems(data: string | string[]) {
if (Array.isArray(data)) {
return data.map((item) => item); // data is string[]
}
return data; // data is string
}Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥