A type guard is a runtime check that lets TypeScript reduce the possible type of a value along one control-flow path.
A custom guard’s return type is a promise; the compiler doesn’t prove that its body checks everything the signature claims.
Keep external data unknown, validate the container, required fields, and domain constraints, then pass the narrowed value to business code.
What it is and why it exists
TypeScript’s static types work before execution, but JavaScript values still arrive at runtime from networks, files, messages, and untyped callers. A parameter declared as string | number can safely use only operations shared by both members. Code needs evidence before it can perform an operation that belongs to just one of them.
A type guard supplies that evidence. typeof value === "string" does more than compute a Boolean; TypeScript also treats value as a string in the branch where it holds. The continuing update of a value’s current possible type through branches, assignments, and reachability is called control-flow narrowing .
Guards don’t add a new runtime mechanism to JavaScript. typeof, instanceof, in, equality checks, and property comparisons run as usual; the compiler merely understands the meaning of some expressions. Interfaces and unions undergo type erasure , so external input still needs a real runtime check.
You use guards with unknown, nullable values, unions, parsed JSON, and callback parameters. A guard answers whether a value currently meets a type. When failure must stop execution, an assertion function states that intent more clearly.
How it works
Every variable has a declared type and an observed type at the current point in control flow. The declared type decides which values may be assigned later. The observed type describes which values can still reach the current path, so a check can narrow it without rewriting the declaration.
Evidence the compiler recognizes
TypeScript recognizes common JavaScript checks and carries their results through short-circuit expressions and early returns. The forms below cover most everyday guards, but each one proves a different runtime fact.
| Check | Good for proving | Watch for |
|---|---|---|
typeof value === "string" | Primitive types and functions | typeof null is "object" |
value instanceof Error | A constructor in the prototype chain | JSON and cross-realm objects may fail |
"id" in value | A property on an object or its prototype chain | It proves neither the value nor ownership |
value === null | An exact value or a relationship to another variable | == null covers both null and undefined |
result.kind === "ok" | A union member with a literal discriminant | The input itself still needs to be trusted |
isShipment(value) | A custom runtime condition | The compiler trusts the predicate signature |
typeof works best for strings, numbers, Booleans, bigint, symbol, undefined, functions, and broad objects. An object branch must exclude null separately. Use Array.isArray() for arrays and instanceof for class instances; the resulting type can’t be more specific than the runtime evidence.
in checks whether a property name can be found on an object, including its prototype chain. With a union, the true branch retains members where the property is required or optional, while the false branch retains members that lack it or declare it as optional. If the input is unknown, first prove that it is a non-null object.
A literal discriminant is usually more stable than probing unrelated properties. Give every union member a kind, status, or type field, and comparing that field narrows the whole object together with its payload. Add a never exhaustiveness check, and introducing a new member turns an omitted branch into a compile error.
Custom predicates and assertions
When built-in checks can’t name a complex object, a function can return a type predicate such as value is Shipment. The caller gets Shipment in the true branch and excludes that type in the false branch. The body must support both meanings; the compiler checks that the predicate type is assignable to the parameter type, but it doesn’t verify the implementation for you.
An assertion function uses asserts value is Type or asserts condition. Code after a normal return receives the narrowed type; when the condition fails, the function should throw or otherwise never return. This fits unrecoverable configuration errors and internal invariants, but it shouldn’t turn ordinary invalid input into a surprise exception.
Type predicates and assertion functions both differ from a type assertion . value as Shipment changes only the checker’s view. It runs no check and converts no value, so as cannot replace boundary validation for data that came from outside the type system.
Examples
The four programs below were executed locally with tsx and type-checked in strict mode. They cover built-in guards, object-shape validation, array predicates, and assertion functions in that order.
Primitive values and class instances
The first example removes union members through early returns. By the last line, the string and Date cases are gone, leaving only number.
type Input = string | number | Date;
function describe(value: Input): string {
if (typeof value === "string") {
return `text:${value.trim().toUpperCase()}`;
}
if (value instanceof Date) {
return `date:${value.toISOString().slice(0, 10)}`;
}
return `number:${value.toFixed(1)}`;
}
const inputs: Input[] = [" ready ", 12.25, new Date("2026-09-04T00:00:00Z")];
for (const input of inputs) {
console.log(describe(input));
}text:READY
number:12.3
date:2026-09-04instanceof Date checks the prototype chain, so its true branch can call toISOString(). That evidence works for Date instances created by code. A date in JSON is still a string and must be parsed before it becomes a Date.
Early returns let the remaining path narrow naturally and are more reliable than adding type assertions to every branch. If Input later gains another member, the numeric operation at the end prompts you to reconsider whether the branches are complete.
Validating a JSON object
The parsed JSON remains unknown at first. isRecord() proves only that the container can be read, then isShipment() checks required fields, a finite number, and the permitted state values.
type Shipment = {
id: string;
state: "packed" | "sent";
weightKg: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isShipment(value: unknown): value is Shipment {
return (
isRecord(value) &&
typeof value.id === "string" &&
(value.state === "packed" || value.state === "sent") &&
typeof value.weightKg === "number" &&
Number.isFinite(value.weightKg) &&
value.weightKg >= 0
);
}
function decodeShipment(raw: string): Shipment | undefined {
const value: unknown = JSON.parse(raw);
return isShipment(value) ? value : undefined;
}
for (const raw of [
'{"id":"PK-42","state":"sent","weightKg":2.5}',
'{"id":"PK-43","state":"waiting","weightKg":"2.5"}',
]) {
const shipment = decodeShipment(raw);
console.log(shipment ? `${shipment.id}:${shipment.state}` : "rejected");
}PK-42:sent
rejectedThe second record has both an invalid state and a string weight, so the guard returns false. Callers don’t repeat the field checks. Only values that pass through the same boundary receive the Shipment type.
typeof value.weightKg === "number" still accepts NaN and infinities. The example continues with Number.isFinite() and a nonnegative check because the domain-type promise should cover the constraints that business code actually relies on.
This guard permits extra fields because it validates the minimum structure needed for a Shipment. If the protocol forbids unknown fields, compare the key set explicitly. Don’t confuse a sufficient structure with an exact structure.
Carrying a predicate into an array filter
Array methods can consume type predicates. With an explicit job is AssignedJob return, the result of filter() is no longer a plain Job[]; every element has a string owner.
type Job = {
id: number;
owner?: string;
};
type AssignedJob = Job & { owner: string };
function hasOwner(job: Job): job is AssignedJob {
return typeof job.owner === "string" && job.owner.trim().length > 0;
}
const jobs: Job[] = [
{ id: 101, owner: "Mina" },
{ id: 102 },
{ id: 103, owner: "" },
];
const assigned = jobs.filter(hasOwner);
for (const job of assigned) {
console.log(`${job.id}:${job.owner.toUpperCase()}`);
}101:MINAThe predicate checks more than property presence: it also rejects empty and whitespace-only strings. AssignedJob records the actual guarantee for callers, so later code needs no non-null assertion.
When a callback returns an ordinary boolean, TypeScript can infer a predicate for some simple expressions, but more involved conditions may not infer one. An explicit predicate on a public helper makes the promise easier to review, provided tests cover both the true and false branches.
An assertion function for fatal input
The caller can’t start a server after receiving an invalid port, so an assertion matches the control flow better than a Boolean return. After a normal return, port is narrowed to number.
function assertPort(value: unknown): asserts value is number {
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < 1 ||
value > 65_535
) {
throw new TypeError("port must be an integer from 1 to 65535");
}
}
function startServer(port: unknown): string {
assertPort(port);
return `listening:${port}`;
}
for (const candidate of [443, "443", 70_000]) {
try {
console.log(startServer(candidate));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`rejected:${message}`);
}
}listening:443
rejected:port must be an integer from 1 to 65535
rejected:port must be an integer from 1 to 65535The assertion checks integer and range constraints, not merely number. Type narrowing and domain validation are two layers here: the static type can express number, while the runtime function also enforces the port rules.
Treating a catch variable as unknown is safer because JavaScript can throw any value. The error instanceof Error branch keeps a standard error message, while the fallback handles strings, objects, and other thrown values.
Pitfalls
Fix: Start from unknown and check every field and domain constraint that business code will read. Test valid values, missing fields, wrong primitives, null, arrays, and boundary numbers instead of exercising just one success case.
Fix: Make x is T true if and only if x belongs to T. If you only want to select a smaller business subset, define a type that accurately represents that subset or return a plain boolean; don’t exclude all of T in the false branch.
Fix: Assign the parsed result to unknown immediately, then call a guard, parser, or schema validator. Reserve type assertions for evidence that code already possesses but the compiler can’t express, and make that evidence testable.
Fix: Check the exact condition you need, such as value !== null && typeof value === "object", or use value !== undefined to exclude only a missing value. Use a truthiness check only when the domain really treats every falsy value as absent.
Fix: Prove a non-null object first, then read and validate the property value. Use Object.hasOwn() when the protocol requires an own property, and check any numeric range, string format, or union-value restriction as well.
Fix: Use instanceof only for instances genuinely created by the same runtime constructor. Check discriminants and structure for data-transfer objects, then construct a domain instance explicitly if class behavior is needed.
A type predicate is a two-way contract
An explicit parameter is Type predicate affects both exits from a condition. A true return narrows the parameter to Type, while false excludes Type from the current union. The predicate can’t merely describe which values this function feels like accepting; it must accurately relate the Boolean result to type membership.
Suppose a parameter is string | number, and a function returns true only for numbers whose absolute value is less than ten but declares value is number. The true branch is fine, but the false branch can still receive 100; the checker incorrectly treats it as a string. This bug hides behind success cases and appears only in negative tests.
If a narrower business concept has a useful type, give it a discriminant and write an accurate guard. If it is only a numeric range that plain number can’t carry, return boolean or construct a validated branded type at the boundary. Don’t use a broad predicate to make a range test look like a complete type relationship.
TypeScript can infer type predicates for some simple functions, including cases where an unmodified parameter is narrowed by a single returned expression. Inference removes a repeated annotation, but it doesn’t remove the need for a semantic review. Once the condition grows complex or defines a public boundary, an explicit signature and negative tests are often easier to maintain.
Preserving evidence when guards compose
Guards can compose through && because the right side runs only after the left side succeeds. Prove that value is a non-null object before reading a field, then prove the field is a string before checking its length or format. That order serves both runtime safety and compiler analysis.
When two predicates compose through ||, the result type should be their union. If either false branch is inaccurate, the combined exclusion magnifies the mistake. Review each short-circuit path as a separate proof instead of looking only at the final return type.
A generic hasProperty() can prove that a key exists and produce Record<K, unknown>, but it can’t discover the value type. The next step must still check record[key]. Separating property presence from property validity is usually easier to audit than hiding a cast inside one generic assertion.
Narrowing, aliases, and mutable state
Control-flow analysis tracks variables and reachable paths; it isn’t a complete runtime ownership system. Reassigning a variable updates its observed type, and changing a discriminant invalidates the fact that selected the original branch. The declared type still decides whether the new assignment is permitted.
Aliases make the problem less obvious. A guard may confirm that profile.name is a string, then another function holding the same object changes it to undefined. The type system can’t prove every side effect of an arbitrary call, so mutable shared objects still need ownership rules, readonly interfaces, or defensive copies after validation.
Apply the same review at an asynchronous boundary. A guard that succeeds before await doesn’t prove that shared external state remains unchanged when execution resumes. Copying validated primitives to local constants or constructing an immutable domain object gives later code a stable snapshot.
Closures extend the path over which a variable is used. If a callback runs after the branch that created it, check whether it captured a stable constant or a mutable variable that can be reassigned. The compiler can prove some last-assignment cases, but that isn’t a concurrency or lifetime guarantee.
Discriminants and exhaustiveness
A discriminated union puts narrowing evidence in the data itself. Every member shares one literal field, and each literal belongs to one member. Checking it narrows the state and its payload together, which scales better than guessing state from several optional fields.
An exhaustiveness check usually assigns the remaining value in a switch to never, or passes it to a function that accepts never. After a new union member is added, the remaining value is no longer never, so the compiler points to the omitted branch. This guarantee covers only the static union; unvalidated JSON can still carry an invalid discriminant at runtime.
When an external system controls the protocol, the boundary guard must first check that the discriminant belongs to the known literal set, then validate the matching payload. kind in value alone can’t establish the correlation between a member and its fields. An accurate parser returns either a valid union or a structured error instead of casting a partly checked object to the whole union.
The boundary between handwritten guards and schema validators
Small, stable objects suit handwritten guards, especially when there are few fields and failures need only an accept-or-reject answer. Keep the guard beside the domain type and lock their relationship down with tests. Once you repeat dozens of fields or maintain deeply recursive structures, a handwritten implementation can drift away from the type quickly.
Schema validators suit shared protocols, nested objects, detailed error paths, and boundaries that reuse rules. Before choosing a library, confirm support for the target runtime, required semantics, and TypeScript version; don’t assume an uninstalled dependency in an example. Whatever tool you use, its real validation result should produce the static type instead of a separately written interface that can drift.
Validation depth depends on the boundary contract. Checking only fields a page reads may suit an internal adapter, while a public API may also need to reject unknown keys, validate every array element, and bound strings or numbers. Put those choices in the parser name, return type, and tests so one vague isValid() doesn’t carry conflicting meanings.
Guards return Booleans and usually don’t explain where validation failed. Forms, configuration files, and bulk imports often need several errors, so a discriminated success-or-failure result works better than stacking assertion functions. Narrowing still happens on the result’s discriminant while the error branch retains enough diagnostic detail.
Designing a reviewable guard API
A guard’s name should state what it proves. isRecord(), isShipment(), and hasOwner() promise a container, a complete domain object, and an owned subset respectively. A vague isValid() tells callers nothing about validation scope and makes tests hard to name.
Prefer an unknown parameter over forcing callers to cast to the target type first. Match the return type to the precision of the checks. A function that validates only part of a structure can return value is Record<"id", unknown> instead of prematurely promising a complete object.
A boundary function must also choose a failure strategy. Interactive input usually needs collected errors, protocol decoding can return a result union, and only unrecoverable startup configuration calls for a throw. Combining all three behaviors in a Boolean guard makes callers guess at both the failure reason and recovery path.
A minimal counterexample matrix
Derive guard tests from the type it claims rather than copying its implementation condition by condition. For a shipment with a string identifier, a state literal, and a finite nonnegative weight, cover at least the inputs below.
| Input class | Example | Failure being checked |
|---|---|---|
| Non-object | null, string, number | The container isn’t readable |
| Wrong container | Array, date instance | The structural meaning differs |
| Missing field | No id | A required field is absent |
| Wrong type | weightKg: "2.5" | No implicit conversion occurs |
| Non-finite number | NaN, Infinity | typeof still reports number |
| Out-of-range number | weightKg: -1 | A domain constraint fails |
| Invalid discriminant | state: "waiting" | It isn’t a known union member |
| Extra field | An added debug | Decide explicitly whether the protocol allows it |
One success fixture isn’t enough either. Cover every valid discriminant, boundary numbers, and permitted optional-field combinations to confirm the guard doesn’t reject valid values. The true and false cases constrain the predicate’s two-way contract together.
Tests should also consume the narrowed result through a real caller. Read every promised field after isShipment(value), or pass the guard to filter() and check its element type. This verifies the runtime behavior and the static experience promised by the signature.
Staying synchronized through changes
When a domain type gains a field or union member, its guard needs an update too. Keeping the type, guard, and boundary tests near one another, then reviewing them as one protocol change, makes omissions easier to see.
Unit tests can’t prove that an arbitrary predicate is fully correct, but they can lock down known boundaries. Contract tests against external samples can then catch renamed fields, changed nullability, and new discriminants that a local interface won’t notice automatically.
If a schema or protocol file generates the type, derive the guard from the same source or make clear that it enforces only extra domain rules. Copying one handwritten interface and another validation implementation leaves two definitions that can both compile while disagreeing.
Export scope
Not every local check deserves to become a public guard. A typeof or discriminant comparison used in one branch is clearer at the call site. Export a named check when several boundaries reuse it and it has independent tests plus a stable contract.
A public guard becomes part of an API. Changing what it accepts alters both runtime behavior and callers’ static narrowing, so review that change as carefully as a parser-signature change.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug