TypeScript Type Guards: Type Predicates and Assertion Functions
We write type predicates with x is T and assertion functions with asserts, and look at TypeScript 5.5's inferred type predicates and narrowing arrays with filter, pitfalls included.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We saw narrowing in the Basics series. Now let's move that check into a function:
type Cat = { kind: "cat"; meow(): void };
type Dog = { kind: "dog"; bark(): void };
function isCat(pet: Cat | Dog): boolean {
return pet.kind === "cat";
}
function speak(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow();
}
}
isCat works correctly; when it returns true, what we have really is a cat. So does this code compile? If not, which line does it get stuck on?
Why Narrowing Gets Lost at the Function Boundary
The code doesn't compile. You get this error on the pet.meow() line:
error TS2339: Property 'meow' does not exist on type 'Cat | Dog'.
Property 'meow' does not exist on type 'Dog'.
Here's why: when TypeScript checks speak, it doesn't look inside isCat; it only looks at its signature. And the signature says "this function returns a boolean." Neither true nor false says anything about pet. Had you written the if (pet.kind === "cat") check directly inside speak, narrowing would have worked; the moment you moved the same check into a function, the information got lost at the function's boundary.
Think of the security guard at a building's entrance. If the guard looks at your ID and just says "okay", nobody inside knows who you are. Or the guard can hand you a badge with your name and access level on it. A function that returns boolean is the guard who says "okay". A type guard is the one who hands you the badge.
Type Predicates: x is T
In TypeScript, that badge is called a type predicate. Instead of boolean, you write parameterName is Type as the return type:
type Cat = { kind: "cat"; meow(): void };
type Dog = { kind: "dog"; bark(): void };
function isCat(pet: Cat | Dog): pet is Cat {
return pet.kind === "cat";
}
function speak(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow(); // pet: Cat
} else {
pet.bark(); // pet: Dog
}
}
speak({ kind: "dog", bark: () => console.log("Woof") }); // Woof
At runtime, pet is Cat is still a plain boolean; this annotation is erased in the compiled JavaScript. What changes is what the compiler knows at the call site. If isCat(pet) returns true, pet narrows to Cat in the if block. In the else block, TypeScript removes Cat from the union and Dog is what's left.
Type guards are most useful for values you know nothing about. The parameter can be unknown:
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
const saved: unknown = JSON.parse('["ts", "js"]');
if (isStringArray(saved)) {
console.log(saved.join(", ")); // ts, js
}
JSON.parse returns any; here we deliberately put it into an unknown variable so that we can't use it without checking. isStringArray really checks at runtime, and TypeScript turns the result of that check into type information. The check and the type information come together in one place.
A Predicate Is a Promise, Not a Proof
There's an important warning here. TypeScript doesn't check whether the body of a predicate is correct. When you write value is number, you're making the compiler a promise, and the compiler trusts you:
function isNumber(value: unknown): value is number {
return typeof value === "string"; // wrong check, the compiler doesn't notice
}
const input: unknown = "42";
if (isNumber(input)) {
console.log(input.toFixed(2)); // TypeError: input.toFixed is not a function
}
The code compiles cleanly and blows up at runtime. In this respect a type predicate is like the as we saw in the Basics series: both are ways of saying "trust me on this." The difference is that the check is gathered in a single function. So keep your type guards short, make it obvious at a glance what they check, and write tests for them when you can. If you make a mistake, every place that uses the guard works with a wrong type.
Assertion Functions: asserts x is T
A type predicate works together with an if. Sometimes, though, you don't want to write an if: if the value isn't what you expect, there's no point in continuing and you want to throw an error. That's what assertion functions are for:
type User = { id: number; name: string };
function assertIsDefined<T>(value: T, label: string): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`${label} is missing`);
}
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
const users = new Map<number, User>([[1, { id: 1, name: "Ada" }]]);
const ada = users.get(1); // User | undefined
assertIsDefined(ada, "user 1");
console.log(ada.name); // Ada
const limit: unknown = 20;
assert(typeof limit === "number", "limit must be a number");
console.log(limit * 2); // 40
const ghost = users.get(2);
assertIsDefined(ghost, "user 2"); // Error: user 2 is missing
An assertion function doesn't return a value. The promise it makes is: "If I return without throwing, this condition is true." And TypeScript treats that promise as valid on the lines after the call. After the assertIsDefined(ada, ...) line, ada is now a User; the undefined possibility is gone. We also used the NonNullable we saw in the previous post here.
The second form, asserts condition, guarantees a condition rather than a specific type. After the assert(typeof limit === "number", ...) call, TypeScript narrows as if you had checked the same condition in an if. The last line shows the runtime side of these functions: ghost doesn't exist, the assertion throws, and the program stops there.
TypeScript 5.5: Inferred Type Predicates
Back to the opening example. Since TypeScript 5.5, all you need to do is delete the : boolean annotation in isCat:
type Cat = { kind: "cat"; meow(): void };
type Dog = { kind: "dog"; bark(): void };
function isCat(pet: Cat | Dog) {
return pet.kind === "cat";
}
function speak(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow();
}
}
function isNonEmptyString(value: unknown) {
return typeof value === "string" && value.length > 0;
}
function shout(value: string | number) {
if (isNonEmptyString(value)) {
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
console.log(value.toUpperCase());
}
}
When you don't write the return type, TypeScript looks at the function body and infers the pet is Cat predicate from the pet.kind === "cat" expression by itself. The real cause of the opening error was the : boolean we wrote; that annotation overrode richer information TypeScript could have inferred.
But not every function that returns a boolean turns into a predicate. The rule is: when the function returns true, the value is of that type, and when it returns false, the value is not of that type; both have to hold. isNonEmptyString breaks this rule. Returning false doesn't mean the value isn't a string; an empty string also gives false. So TypeScript infers no predicate, the return type stays boolean, and there's no narrowing inside shout. In a case like this, writing the predicate explicitly is your decision. For the inference to work, the function must also have no explicit return type, contain a single return statement, and not mutate its parameter.
Narrowing with filter
The place you feel this feature most is the array filter method. One of filter's overloads narrows the type of the resulting array when the callback is a type predicate. Before TypeScript 5.5 you had to mark callbacks by hand for that. Now you usually don't:
const values = [1, null, 2, undefined, 3];
const numbers = values.filter((v) => v !== null && v !== undefined); // number[]
const shorter = values.filter((v) => v != null); // number[]
const truthy = values.filter(Boolean); // (number | null | undefined)[]
const alsoTruthy = values.filter((v) => !!v); // (number | null | undefined)[]
const explicit = values.filter((v): v is number => typeof v === "number"); // number[]
console.log(numbers.map((n) => n * 10)); // [ 10, 20, 30 ]
In the first two lines TypeScript infers the v is number predicate from the callback, and the result is number[]. filter(Boolean), despite being a popular JavaScript idiom, doesn't narrow the type: the signature of the Boolean function isn't a predicate. (v) => !!v doesn't narrow either, because it runs into the rule we just saw: 0 is falsy too, so returning false doesn't prove the value isn't a number. In these two cases, either write the comparison explicitly or provide the predicate yourself as in the last example.
Common Pitfalls
// Error: A type predicate's type must be assignable to its parameter's type.
function isId(value: string): value is number {
return true;
}
const assertArrow = (condition: unknown, message: string): asserts condition => {
if (!condition) throw new Error(message);
};
function check(value: string | null) {
// Error: Assertions require every name in the call target to be declared with an explicit type annotation.
assertArrow(value, "value is required");
}
const assertTyped: (condition: unknown, message: string) => asserts condition = (condition, message) => {
if (!condition) throw new Error(message);
};
function checkTyped(value: string | null) {
assertTyped(value, "value is required");
console.log(value.length);
}
First, the type in a predicate must be a subset of the parameter's type. For a parameter that takes a string, you can't say "this is actually a number"; you get a TS2677 error. A predicate is narrowing, not a change of type.
Second, assertions written as arrow functions. If you assign an assertion function to a variable and call it through that variable, you have to write the variable's type explicitly. Otherwise you get a TS2775 error. The reason is that TypeScript wants to know the type of the thing being called up front while it does its flow analysis. The simplest fix is to write assertions as function declarations; if you insist on an arrow function, give the variable a type as in the assertTyped example.
Third is the lying predicate we saw above. The compiler trusts your predicates; living up to that trust is your responsibility.
A Small Experiment
★☆☆ Write an isCircle type guard for type Circle = { kind: "circle"; radius: number } and type Square = { kind: "square"; size: number }. Then delete the return type and check whether TypeScript infers the predicate by itself by hovering over the function in your editor.
★★☆ Predict: in const ids = ["1", "", "3"].filter((s) => s.length > 0);, what is the type of ids? And for const xs = [1, "a", 2].filter((x) => typeof x === "number");?
★★★ Write an isTodo type guard that checks whether an unknown value has the shape { id: number; title: string }. Then write the same check as an assertIsTodo assertion function, and compare how the calling code differs between the two.
As in the last exercise, you need type guards most for data coming from the outside world, and that data usually arrives asynchronously, at the end of a network request. In the next post we'll talk about the types of Promises and async functions, and make a response from fetch safe with the tools from this post.