TypeScript 15 ๐ท User-Defined Type Guards and Predicates
The built-in guards โ typeof, instanceof, in โ cover a lot of ground, but they can’t express every check. “A string with length greater than zero,” “an object with id as a number and name as a string,” “a Result with ok: true” โ those need logic, not a single operator. That’s what user-defined type guards are for. A user-defined guard is a function whose return type is a type predicate (value is T), and TypeScript treats its true result as proof that the value is T. Predicates are how you extend narrowing beyond the built-ins โ the tool for validating external data, distinguishing complex shapes, and turning reusable checks into first-class narrowing.
Key point: A type predicate says “if this function returns true, then value is T.” The compiler trusts that promise โ it doesn’t verify the function’s body. Write the check correctly, and you get runtime validation with compile-time narrowing. Write it wrong, and TypeScript will silently accept a lie. Predicates are powerful because the compiler trusts them; they’re dangerous for the same reason.
What a user-defined guard is
A user-defined type guard is a function that returns a type predicate.
function isString(value: unknown): value is string {
return typeof value === 'string';
}
That : value is string is the predicate. It tells TypeScript: “When this returns true, value is a string.”
Using it:
function handle(value: unknown): void {
if (isString(value)) {
value.toUpperCase(); // value is string here
}
}
Inside the if, value narrows to string. The compiler trusted the predicate.
The predicate syntax:
function fnName(param: InputType): param is OutputType {
// return a boolean
}
parammust be a parameter name (not an expression)InputTypeis the parameter’s declared typeOutputTypeis the narrower type when the function returnstrue
Predicates on other parameters: The predicate can name any parameter, not just the first.
function hasKey<T extends object>(
obj: T,
key: string
): key is keyof T {
return key in obj;
}
But key is keyof T isn’t a valid predicate โ predicates narrow to types, and keyof T is a type, so it can work. More commonly, predicates on secondary parameters are rare; the main subject is usually the first or only parameter.
Predicates on this:
class FileSystem {
isDirectory(): this is Directory {
return (this as any).type === 'dir';
}
}
this is T narrows the current instance.
Why user-defined guards exist: Built-in guards are limited to primitives (typeof), classes (instanceof), and property presence (in). Real types need richer checks โ a predicate can combine any number of runtime conditions and express them as a single narrowing step.
Why the compiler trusts predicates: Verifying a predicate would require reasoning about arbitrary code โ a hard problem. TypeScript takes the pragmatic path: you write the check, you assert the type, the compiler trusts you. This is the same trust model as
as, but predicates have the advantage of running a real check at runtime. You’re telling the compiler “this function returnstrueif and only if the value isT” โ and you’re responsible for making that true.
Predicates vs boolean returns
The difference between a predicate and a plain boolean function is one annotation.
// Boolean function โ no narrowing
function checkString(value: unknown): boolean {
return typeof value === 'string';
}
// Type predicate โ narrows
function isString(value: unknown): value is string {
return typeof value === 'string';
}
Both return boolean at runtime. Only the predicate narrows.
if (checkString(x)) {
// x is still unknown โ no narrowing
}
if (isString(x)) {
// x is string โ narrowed by the predicate
}
Why the difference: boolean is a general type โ the compiler has no idea what it means. value is string is a specific claim โ the compiler knows exactly what to narrow. A boolean return tells you nothing about the input; a predicate does.
Predicates are boolean subtypes: Every predicate function returns a boolean. The narrowing information is additional. A predicate can be used wherever a boolean function is expected โ the extra narrowing just applies when called directly in a condition.
Why the naming convention: Predicates conventionally start with is, has, can, or should โ isString, hasData, canEdit. The naming signals “this is a narrowing check,” making code more readable and distinguishing predicates from plain checks.
Why not just infer predicates: The compiler could, in theory, analyze
typeof value === 'string' && value.length > 0and infer that it means “non-empty string.” TypeScript doesn’t do this because it would require deep analysis of arbitrary logic. Instead, the predicate annotation is a contract โ you state the meaning, and the compiler trusts the body. Inference is limited to the built-in guards, where the semantics are simple and known.
Writing a predicate for a complex type
The most common use case: narrowing unknown to a specific object shape.
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as { id: unknown }).id === 'number' &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string' &&
'email' in value &&
typeof (value as { email: unknown }).email === 'string'
);
}
Every property is checked:
typeof value === 'object'โ it’s an objectvalue !== nullโ and not null (thetypeof nulltrap)'id' in valueโ hasidtypeof (value as { id: unknown }).id === 'number'โ andidis a number
The as { id: unknown } casts are needed because value is still object at that point โ TypeScript doesn’t know it has id. Inside the predicate, all these casts are safe: you’re building the proof that value is a User.
Cleaner with a helper:
function hasProp<K extends string, V>(
obj: unknown,
key: K,
check: (v: unknown) => v is V
): obj is Record<K, V> {
return (
typeof obj === 'object' &&
obj !== null &&
key in obj &&
check((obj as Record<string, unknown>)[key])
);
}
Now isUser is shorter:
function isUser(value: unknown): value is User {
return (
hasProp(value, 'id', isNumber) &&
hasProp(value, 'name', isString) &&
hasProp(value, 'email', isString)
);
}
Helpers like hasProp reduce the boilerplate of writing complex predicates. But they’re optional โ a plain predicate works, just verbosely.
Validating arrays:
function isUserArray(value: unknown): value is User[] {
return Array.isArray(value) && value.every(isUser);
}
The every narrowing: value.every(isUser) โ after Array.isArray, value is unknown[]. every calls isUser on each element and narrows if all pass โ but TypeScript doesn’t automatically narrow the array to User[] from this alone. You need the predicate on the outer function.
Validating nested objects:
interface Address {
street: string;
city: string;
}
interface User {
id: number;
address: Address;
}
function isAddress(value: unknown): value is Address {
return (
typeof value === 'object' && value !== null &&
'street' in value &&
typeof (value as Address).street === 'string' &&
'city' in value &&
typeof (value as Address).city === 'string'
);
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' && value !== null &&
'id' in value &&
typeof (value as { id: unknown }).id === 'number' &&
'address' in value &&
isAddress((value as { address: unknown }).address)
);
}
Predicates compose โ isUser calls isAddress, which handles the nested shape.
Why predicates need the casts: Inside the predicate,
valuestarts asunknown.'id' in valueprovesidexists but doesn’t tell TypeScript its type. Casting to{ id: unknown }lets you access.idand check it withtypeof. It looks noisy, but each cast is temporary โ the end result is a fully-narrowedUser. Helper functions likehasPropreduce the noise.
Predicates in arrays
Array.filter with a predicate narrows the array’s element type.
const mixed: (string | number)[] = [1, 'a', 2, 'b', 3];
const strings = mixed.filter(isString);
// strings: string[]
isString narrows the elements โ the filter result is string[], not (string | number)[].
Without a predicate:
const strings = mixed.filter(x => typeof x === 'string');
// strings: (string | number)[] โ no narrowing
The arrow function returns boolean, so the filter result keeps the original element type. TypeScript has a special case for typeof x === 'string' inline โ sometimes it works โ but a named predicate is reliable.
Filtering discriminated unions:
type Message =
| { kind: 'text'; text: string }
| { kind: 'image'; url: string };
function isText(m: Message): m is { kind: 'text'; text: string } {
return m.kind === 'text';
}
const messages: Message[] = [/* ... */];
const texts = messages.filter(isText);
// texts: { kind: 'text'; text: string }[]
The result is typed as the narrowed variant โ you can access .text on every element.
Filtering nullable arrays:
function isNotNull<T>(x: T | null | undefined): x is T {
return x !== null && x !== undefined;
}
const values: (string | null)[] = ['a', null, 'b', null];
const strings = values.filter(isNotNull);
// strings: string[]
This is a common pattern โ the isNotNull predicate removes nulls and narrows.
Filtering unknown arrays:
const parsed: unknown[] = [1, 'a', null, { id: 1 }, 2];
const numbers = parsed.filter(isNumber);
// numbers: number[]
The filter narrows to the subtype, dropping the non-numbers at the type level.
Why filter + predicate is powerful:
.filterwithout a predicate keeps the union โ every element might still be any branch. With a predicate, TypeScript knows the result only contains the narrowed type. This is how you getstring[]from(string | number)[],User[]fromunknown[], and specific variants from unions. It’s the standard idiom for typed filtering.
Predicates across function boundaries
Narrowing from typeof and in works within a single scope. It resets inside callbacks and after await. Predicates cross those boundaries.
function process(data: unknown): void {
if (isUser(data)) {
setTimeout(() => {
data.name; // โ
data is User โ predicate narrowed it
}, 100);
}
}
The predicate’s narrowing persists inside the callback. The compiler trusts that isUser(data) returning true means data is a User for the rest of the block โ including closures.
Without a predicate:
function process(data: unknown): void {
if (typeof data === 'object' && data !== null && 'name' in data) {
setTimeout(() => {
data.name; // โ narrowing was lost
}, 100);
}
}
Narrowing via typeof and in is scope-local. Inside the callback, the compiler reverts to the outer type.
Why predicates work across boundaries: A predicate is an assertion โ once isUser(data) returns true, the compiler assumes data is User for the rest of the enclosing scope. This holds even inside closures because the compiler treats the predicate’s success as a state change, not a temporary condition. The narrowing is committed.
The trade-off: You’re trusting the predicate. If isUser returns true for something that isn’t a User, the callback will fail at runtime. The predicate’s correctness is on you.
const narrowing also crosses boundaries:
if (typeof data === 'object' && data !== null && 'name' in data) {
const user = data; // captured as const
setTimeout(() => {
user.name; // โ
user is { name: unknown } โ narrowed
}, 100);
}
Assigning the narrowed value to a const captures the narrowed type. Either approach works.
Why boundary-crossing matters: Async code is full of callbacks โ
setTimeout,.then, event handlers,await. Narrowing viatypeof/inis lost because the compiler can’t know when the callback runs or whether the variable changed. Predicates commit the narrowing. That’s why predicates are the tool of choice for validating data that will be used asynchronously.
Assertion functions โ asserts
An assertion function narrows by throwing when a condition fails. It’s the linear counterpart to predicates.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Expected a string');
}
}
function handle(value: unknown): void {
assertIsString(value);
value.toUpperCase(); // value is string โ narrowed by the assertion
}
After the call, value is narrowed. If the check failed, the function threw โ so execution never reached the next line.
asserts condition โ without a type:
function assert(condition: unknown, msg?: string): asserts condition {
if (!condition) throw new Error(msg ?? 'Assertion failed');
}
function f(x: unknown): void {
assert(typeof x === 'string');
// x is narrowed to string from here
}
asserts condition narrows based on the boolean expression passed in. It’s TypeScript’s way of understanding Node’s assert โ if the function throws on falsy, the code after is safe.
Assertion functions must be function declarations:
// โ
works
function assertIsString(x: unknown): asserts x is string { }
// โ arrow functions can't
const assertIsString = (x: unknown): asserts x is string => { };
Arrow functions can’t carry an asserts return type. Use a function declaration.
asserts vs predicates:
x is T | asserts x is T | |
|---|---|---|
| Form | if (isString(x)) | assertIsString(x) |
| Control flow | Branches | Linear |
| Narrows on | true return | Successful return |
| Throws | No | Yes |
| Use case | Branching | Validate-and-continue |
When to use which:
- Predicates when you want to branch โ do something if
true, else something else - Assertion functions when you want to validate and continue โ throw on failure, proceed with the narrow type
Assertion functions compose:
function assertIsNumber(x: unknown): asserts x is number {
if (typeof x !== 'number') throw new Error('Expected number');
}
function assertIsPositive(x: unknown): asserts x is number {
assertIsNumber(x);
if (x <= 0) throw new Error('Expected positive');
}
Each assertion narrows further โ the second calls the first and adds a check.
asserts on this:
class Maybe {
value: string | null = null;
assertValue(this: Maybe & { value: string }): asserts this is { value: string } {
if (this.value === null) throw new Error('No value');
}
}
Rare, but supported.
Why
assertsexists: Sometimes anifblock isn’t the right shape. You want to validate at the top of a function, then work with the narrowed type in the rest of the body.assertsgives you that: the check happens, it throws on failure, and the rest of the function sees the narrow type. This is how Node’sassertmodule works โ and how you make validation functions that integrate with TypeScript’s narrowing.
Predicates vs assertions
Both narrow. They differ in shape and intent.
Predicates โ branching:
if (isUser(data)) {
// data is User
} else {
// data is not User
}
You have two branches โ one where the value is T, one where it isn’t. Both are valid paths.
Assertion functions โ validating:
assertUser(data);
// data is User โ execution only continues if it was
There’s no “else” branch โ failure throws. Useful for input validation at function boundaries.
Which one to reach for:
| Situation | Tool |
|---|---|
| Branching on the check | Predicate |
| Validating and continuing | Assertion function |
| Reusable check across many sites | Predicate |
| Fail-fast on bad input | Assertion function |
| Filtering arrays | Predicate |
| Function precondition | Assertion function |
Predicates as components of assertions:
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error('Not a user');
}
The assertion function delegates to the predicate. This is a common pattern โ one predicate, used both for branching checks and for throwing assertions.
Predicates return booleans โ composable:
function isAdmin(user: unknown): user is Admin {
return isUser(user) && user.role === 'admin';
}
Predicates compose with &&, ||, and !. Assertion functions don’t compose as values โ they’re statements.
Why both exist: Branching and validating are different operations. A predicate says “is this a
T?” โ it’s a question. An assertion function says “ensure this is aT, or fail” โ it’s a command. Having both lets you write natural code for each case. In practice, you write predicates and wrap them in assertion functions when you need fail-fast behavior.
Predicates with generics
Predicates can be generic โ narrow a value to a parameterized type.
function isArrayOf<T>(
value: unknown,
check: (x: unknown) => x is T
): value is T[] {
return Array.isArray(value) && value.every(check);
}
const data: unknown = [1, 2, 3];
if (isArrayOf(data, isNumber)) {
// data is number[]
}
The type parameter T is inferred from the check function.
Predicates on generic unions:
function isNonNullish<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
const values: (string | null)[] = ['a', null, 'b'];
const strings = values.filter(isNonNullish);
// strings: string[]
T is inferred as string, and the predicate narrows away the null.
Predicates on keyed objects:
function hasProp<T, K extends keyof T>(
obj: T,
key: K
): obj is T & Required<Pick<T, K>> {
return obj[key] !== undefined;
}
Complex โ rarely needed. Most predicates work on unknown and narrow to a concrete type.
Why generic predicates are less common: Most predicates narrow unknown to a specific shape. Generics help with reusable utilities like isNonNullish or isArrayOf, but the common case is a plain predicate on unknown.
Why
isNonNullishis idiomatic: Removingnullandundefinedfrom an array is a universal need. A generic predicate works for any element type โ you write it once and use it everywhere. This is where generics pay off in predicates: reusable narrowing utilities that work across types.
A full example
A validation module that uses predicates, assertions, and generic utilities.
// ============================================
// PRIMITIVE PREDICATES
// ============================================
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
// ============================================
// GENERIC UTILITIES
// ============================================
function isArrayOf<T>(
value: unknown,
check: (x: unknown) => x is T
): value is T[] {
return Array.isArray(value) && value.every(check);
}
function isNonNullish<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
// ============================================
// DOMAIN PREDICATES
// ============================================
interface Product {
id: string;
name: string;
price: number;
tags: string[];
}
function isProduct(value: unknown): value is Product {
return (
typeof value === 'object' &&
value !== null &&
'id' in value && isString((value as { id: unknown }).id) &&
'name' in value && isString((value as { name: unknown }).name) &&
'price' in value && isNumber((value as { price: unknown }).price) &&
'tags' in value && isArrayOf((value as { tags: unknown }).tags, isString)
);
}
// ============================================
// ASSERTION FUNCTIONS
// ============================================
function assertProduct(value: unknown): asserts value is Product {
if (!isProduct(value)) {
throw new Error(`Not a product: ${JSON.stringify(value)}`);
}
}
function assertArrayOfProducts(value: unknown): asserts value is Product[] {
if (!isArrayOf(value, isProduct)) {
throw new Error('Not an array of products');
}
}
// ============================================
// USAGE
// ============================================
const raw: unknown = JSON.parse(`
[
{ "id": "p-1", "name": "Book", "price": 12.5, "tags": ["read"] },
{ "id": "p-2", "name": "Pen", "price": 1.75, "tags": [] }
]
`);
assertArrayOfProducts(raw);
// raw is Product[] from here
const total = raw.reduce((sum, p) => sum + p.price, 0);
console.log(`Total: $${total.toFixed(2)}`);
// Filter with predicate
const mixed: unknown[] = [1, 'a', null, 2, 'b', undefined, 3];
const numbers = mixed.filter(isNumber);
console.log(numbers); // [1, 2, 3]
const noNulls = mixed.filter(isNonNullish);
console.log(noNulls); // [1, 'a', 2, 'b', 3]
Every pattern is used: primitive predicates, generic isArrayOf, domain predicate isProduct, assertion functions, and .filter with predicates.
Why this pattern scales: It’s the shape of a real validation module. Primitive predicates build domain predicates. Domain predicates build assertions. Generic utilities (
isArrayOf,isNonNullish) cover cross-cutting cases. The result is a layered set of checks that compose cleanly. Every narrowing is runtime-checked, every typed value was validated.
Complete Example Session
# ============================================
# PART 1: BASIC PREDICATE
# ============================================
cat > basic.ts << 'EOF'
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function handle(value: unknown): void {
if (isString(value)) {
console.log(value.toUpperCase()); // value is string
} else {
console.log('not a string');
}
}
handle('hello');
handle(42);
// vs boolean โ no narrowing
function checkString(value: unknown): boolean {
return typeof value === 'string';
}
const x: unknown = 'hi';
if (checkString(x)) {
// x is still unknown
}
EOF
npx tsc --noEmit basic.ts
# (no errors)
# ============================================
# PART 2: COMPLEX SHAPE
# ============================================
cat > complex.ts << 'EOF'
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as { id: unknown }).id === 'number' &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string' &&
'email' in value &&
typeof (value as { email: unknown }).email === 'string'
);
}
const raw: unknown = JSON.parse('{"id":1,"name":"Alice","email":"a@b.c"}');
if (isUser(raw)) {
console.log(raw.name); // User
}
EOF
npx tsc --noEmit complex.ts
# (no errors)
# ============================================
# PART 3: FILTER WITH PREDICATE
# ============================================
cat > filter.ts << 'EOF'
function isString(v: unknown): v is string {
return typeof v === 'string';
}
function isNumber(v: unknown): v is number {
return typeof v === 'number';
}
function isNotNull<T>(v: T | null | undefined): v is T {
return v !== null && v !== undefined;
}
const mixed: unknown[] = [1, 'a', null, 2, undefined, 'b', 3];
const strings = mixed.filter(isString); // string[]
const numbers = mixed.filter(isNumber); // number[]
const nonNull = mixed.filter(isNotNull); // (string | number)[]
console.log(strings, numbers, nonNull);
EOF
npx tsc --noEmit filter.ts
# (no errors)
# ============================================
# PART 4: DISCRIMINATED UNION PREDICATE
# ============================================
cat > discriminated.ts << 'EOF'
type Message =
| { kind: 'text'; text: string }
| { kind: 'image'; url: string };
function isText(m: Message): m is { kind: 'text'; text: string } {
return m.kind === 'text';
}
const messages: Message[] = [
{ kind: 'text', text: 'hello' },
{ kind: 'image', url: '/a.png' },
{ kind: 'text', text: 'world' }
];
const texts = messages.filter(isText);
console.log(texts.map(t => t.text)); // ['hello', 'world']
EOF
npx tsc --noEmit discriminated.ts
# (no errors)
# ============================================
# PART 5: ASSERTION FUNCTIONS
# ============================================
cat > assertions.ts << 'EOF'
interface User { id: number; name: string; }
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null
&& typeof (x as User).id === 'number'
&& typeof (x as User).name === 'string';
}
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error('Not a user');
}
function process(x: unknown): void {
assertUser(x);
console.log(x.name); // x is User
}
process({ id: 1, name: 'Alice' });
EOF
npx tsc --noEmit assertions.ts
# (no errors)
# ============================================
# PART 6: GENERIC PREDICATES
# ============================================
cat > generic.ts << 'EOF'
function isString(v: unknown): v is string {
return typeof v === 'string';
}
function isArrayOf<T>(
value: unknown,
check: (x: unknown) => x is T
): value is T[] {
return Array.isArray(value) && value.every(check);
}
function isNonNullish<T>(v: T | null | undefined): v is T {
return v !== null && v !== undefined;
}
const strings: unknown = ['a', 'b'];
if (isArrayOf(strings, isString)) {
console.log(strings.join(',')); // string[]
}
const mixed: (string | null)[] = ['a', null, 'b'];
console.log(mixed.filter(isNonNullish)); // string[]
EOF
npx tsc --noEmit generic.ts
# (no errors)
# ============================================
# PART 7: FULL EXAMPLE
# ============================================
cat > example.ts << 'EOF'
interface Product {
id: string;
name: string;
price: number;
}
function isString(v: unknown): v is string { return typeof v === 'string'; }
function isNumber(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v);
}
function isProduct(v: unknown): v is Product {
return (
typeof v === 'object' && v !== null &&
'id' in v && isString((v as { id: unknown }).id) &&
'name' in v && isString((v as { name: unknown }).name) &&
'price' in v && isNumber((v as { price: unknown }).price)
);
}
function isArrayOf<T>(v: unknown, check: (x: unknown) => x is T): v is T[] {
return Array.isArray(v) && v.every(check);
}
function assertProducts(v: unknown): asserts v is Product[] {
if (!isArrayOf(v, isProduct)) throw new Error('Invalid products');
}
const raw: unknown = JSON.parse('[{"id":"p","name":"Book","price":12.5}]');
assertProducts(raw);
const total = raw.reduce((s, p) => s + p.price, 0);
console.log(`Total: $${total.toFixed(2)}`);
EOF
npx tsc --noEmit example.ts
# (no errors)
# ============================================
# PART 8: COMPILE AND RUN
# ============================================
npx tsc basic.ts complex.ts filter.ts discriminated.ts assertions.ts generic.ts example.ts
node basic.js
# [ HELLO ]
# [ not a string ]
node complex.js
# [ Alice ]
node filter.js
# [ [ 'a', 'b' ] [ 1, 2, 3 ] [ 1, 'a', 2, 'b', 3 ] ]
node discriminated.js
# [ [ 'hello', 'world' ] ]
node assertions.js
# [ Alice ]
node generic.js
# [ a,b ]
# [ [ 'a', 'b' ] ]
node example.js
# [ Total: $12.50 ]
Quick Reference
Predicate Syntax
| Form | Example |
|---|---|
| Basic | function f(x: unknown): x is T |
On this | function f(): this is T |
| On other param | function f(a: X, b: unknown): b is T |
| Generic | function f<T>(x: unknown): x is T[] |
| Array element | function f<T>(x: unknown, c: (y: unknown) => y is T): x is T[] |
Predicate Naming Conventions
| Prefix | Example | Meaning |
|---|---|---|
is | isString | Type check |
has | hasData | Property presence |
can | canEdit | Capability |
should | shouldRender | Policy |
asserts | assertUser | Assertion function |
Predicate vs Boolean
| Function | Returns | Narrows |
|---|---|---|
function f(x): boolean | boolean | โ |
function f(x): x is T | boolean | โ |
function f(x): asserts x is T | void | โ (throws) |
function f(x): asserts x | void | โ (condition) |
When to Use Predicates
| Use case | Recommended |
|---|---|
| Validate external data | โ Predicate |
Narrow arrays with .filter | โ Predicate |
| Reusable checks | โ Predicate |
| Narrow in callbacks | โ Predicate |
| Fail-fast validation | Assertion function |
| Simple primitive check | typeof |
| Class check | instanceof |
| Object shape check | in |
Predicate Composition
| Pattern | Effect |
|---|---|
isA(x) && isB(x) | Both true |
isA(x) || isB(x) | Either true |
!isA(x) | Negation |
isArrayOf(xs, isA) | Array of A |
isNonNullish(x) | Removes null/undefined |
assertX(x) after !isX(x) | Throws or continues |
Common Predicate Utilities
| Predicate | Purpose |
|---|---|
isString(x) | Narrow to string |
isNumber(x) | Narrow to number |
isBoolean(x) | Narrow to boolean |
isNonNullish(x) | Remove null/undefined |
isArrayOf(xs, isT) | Narrow to T[] |
hasProp(obj, key, check) | Narrow object property |
isObject(x) | Narrow to object (not null) |
Assertion Functions
| Form | Narrows |
|---|---|
asserts x is T | On successful return |
asserts condition | On successful return |
function declaration | Required |
| Arrow function | โ not allowed |
Predicates and Arrays
| Operation | Result |
|---|---|
xs.filter(isT) | T[] |
xs.filter(x => typeof x === 'string') | (typeof xs)[number][] โ โ ๏ธ no narrowing |
xs.every(isT) | boolean โ does NOT narrow xs |
xs.some(isT) | boolean โ does not narrow |
Common Mistakes
| Mistake | Problem |
|---|---|
boolean return | No narrowing |
| Predicate that lies | Runtime crashes |
| Forgetting null check | typeof null === 'object' |
| Arrow assertion function | Compile error |
| Predicate on expression | Must be a parameter |
Best Practices
โ Do This:
// Name predicates with `is` or `has`
function isUser(x: unknown): x is User { } // โ
// Return boolean at runtime, predicate at compile time
function isNumber(x: unknown): x is number {
return typeof x === 'number' && Number.isFinite(x); // โ
}
// Use predicates with filter
const users = items.filter(isUser); // โ
// Write predicates for `unknown` inputs at boundaries
const data: unknown = JSON.parse(raw);
if (isConfig(data)) useConfig(data); // โ
// Wrap predicates in assertions for fail-fast
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error('Not a user'); // โ
}
// Use `isNonNullish` to remove nulls
const values = mixed.filter(isNonNullish); // โ
// Compose predicates
function isAdmin(u: unknown): u is Admin {
return isUser(u) && u.role === 'admin'; // โ
}
// Check null before treating as object
function isObject(x: unknown): x is object {
return typeof x === 'object' && x !== null; // โ
}
// Use generic predicates for reusable utilities
function isArrayOf<T>(x: unknown, c: (v: unknown) => v is T): x is T[] { } // โ
โ Don’t Do This:
// Don't use boolean return when you want narrowing
function isUser(x: unknown): boolean { /* no narrowing */ } // โ ๏ธ
// Don't write predicates that lie
function isUser(x: unknown): x is User { return true; } // โ
// Don't skip null check with typeof object
function isObject(x: unknown): x is object {
return typeof x === 'object'; // โ ๏ธ null passes // โ ๏ธ
}
// Don't use arrow functions for asserts
const assertUser = (x: unknown): asserts x is User => { }; // โ compile error
// Don't forget the parameter name in the predicate
function isString(value: unknown): string is value { } // โ syntax error
// Don't rely on `every` to narrow the array
if (xs.every(isUser)) {
// xs is still unknown[] โ not User[] // โ ๏ธ
}
// Don't use `any` inside predicates
function isUser(x: any): x is User { return true; } // โ ๏ธ use unknown
// Don't skip runtime validation at boundaries
const user = data as User; // โ use predicate
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
boolean return | No narrowing | Use x is T |
| Predicate lies | Runtime crash | Write correct body |
typeof null === 'object' | Null passes as object | Check x !== null |
.every doesn’t narrow outer | Type remains wide | Wrap in outer predicate |
| Arrow assertion function | Compile error | Use function |
| Predicate on expression | Must be a parameter | Assign to variable |
| Nested casts | Noisy but valid | Use hasProp helper |
Predicate on any | Silently narrows | Use unknown |
| Forgetting generics | Type lost | isArrayOf<T> |
Predicate in a .some | No narrowing | Use .filter for narrowing |
Real-World Examples
1. Basic predicate
function isString(x: unknown): x is string {
return typeof x === 'string';
}
2. Number predicate
function isNumber(x: unknown): x is number {
return typeof x === 'number' && Number.isFinite(x);
}
3. Non-null predicate
function isNonNullish<T>(x: T | null | undefined): x is T {
return x !== null && x !== undefined;
}
4. Object predicate
function isObject(x: unknown): x is object {
return typeof x === 'object' && x !== null;
}
5. Interface predicate
function isUser(x: unknown): x is User {
return isObject(x) && 'id' in x && typeof (x as User).id === 'number';
}
6. Discriminated union predicate
function isText(m: Message): m is { kind: 'text'; text: string } {
return m.kind === 'text';
}
7. Array predicate
function isArrayOf<T>(x: unknown, c: (v: unknown) => v is T): x is T[] {
return Array.isArray(x) && x.every(c);
}
8. Nullable array filter
const values = mixed.filter(isNonNullish);
9. Filter union
const users = items.filter(isUser);
10. Filter discriminated union
const texts = messages.filter(isText);
11. Assertion function
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error('Not a user');
}
12. Assert condition
function assert(c: unknown, msg?: string): asserts c {
if (!c) throw new Error(msg ?? 'Assertion failed');
}
13. Composition
function isAdmin(x: unknown): x is Admin {
return isUser(x) && x.role === 'admin';
}
14. JSON validation
const data: unknown = JSON.parse(raw);
if (isConfig(data)) useConfig(data);
15. API response validation
const json: unknown = await res.json();
if (isUser(json)) return json;
throw new Error('Invalid user');
16. Catch-block narrowing
if (isError(err)) console.error(err.message);
17. Nested validation
function isOrder(x: unknown): x is Order {
return isObject(x) && isUser((x as Order).user) && isArrayOf((x as Order).items, isItem);
}
18. Property check helper
function hasProp<T extends string>(
obj: unknown,
key: T,
check: (v: unknown) => boolean
): boolean {
return isObject(obj) && key in obj && check((obj as Record<string, unknown>)[key]);
}
19. Predicate on this
class Node { isLeaf(): this is Leaf { /* ... */ } }
20. Predicate with asserts
function assertIsArrayOf<T>(
x: unknown,
check: (v: unknown) => v is T
): asserts x is T[] {
if (!isArrayOf(x, check)) throw new Error('Not an array');
}
Visual: Predicate Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function isUser(x: unknown): x is User โ
โ โ โ
โ โผ โ
โ return true โ x is User โ
โ return false โ x is unknown โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ if (isUser(data)) { โ
โ // data: User โ
โ } else { โ
โ // data: unknown โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Boolean vs Predicate
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Boolean function โ
โ โ
โ function checkString(x: unknown): boolean โ
โ โ
โ if (checkString(x)) { โ
โ // x is still unknown โ no narrowing โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Type predicate โ
โ โ
โ function isString(x: unknown): x is string โ
โ โ
โ if (isString(x)) { โ
โ // x is string โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Complex Predicate Structure
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function isUser(x: unknown): x is User { โ
โ return ( โ
โ typeof x === 'object' && โ is obj โ
โ x !== null && โ not nullโ
โ 'id' in x && โ has id โ
โ typeof x.id === 'number' && โ id num โ
โ 'name' in x && โ has nameโ
โ typeof x.name === 'string' โ name strโ
โ ); โ
โ } โ
โ โ
โ All checks must pass to narrow โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Filter with Predicate
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Before filter โ
โ โ
โ const mixed: (string | number)[] = [...] โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ .filter(isString)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ After filter โ
โ โ
โ const strings: string[] = [...] โ
โ โ
โ Elements narrowed โ only strings remain โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Predicate vs Assertion
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Predicate โ branches โ
โ โ
โ if (isUser(x)) { โ
โ // x is User โ
โ } else { โ
โ // x is unknown โ
โ } โ
โ โ
โ Both paths run โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Assertion โ throws โ
โ โ
โ assertUser(x); โ
โ // x is User (or threw earlier) โ
โ โ
โ Only the success path runs โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Composing Predicates
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Primitive predicates โ
โ โ
โ isString, isNumber, isBoolean, isObject โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ compose
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Domain predicates โ
โ โ
โ isUser = isObject && hasId && hasName โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ compose
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Collection predicates โ
โ โ
โ isUsers = Array.isArray && every(isUser) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ wrap
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Assertion functions โ
โ โ
โ assertUsers(x) โ throws if not โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Predicate Across Callback Boundary
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Without predicate โ
โ โ
โ if (typeof x === 'object' && x !== null) { โ
โ setTimeout(() => { โ
โ x.name; โ x is object | null again โ
โ }); โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ With predicate โ
โ โ
โ if (isUser(x)) { โ
โ setTimeout(() => { โ
โ x.name; โ
x is User โ
โ }); โ
โ } โ
โ โ
โ Predicate commits the narrowing โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Predicate Trust Model
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ You write: โ
โ โ
โ function isUser(x: unknown): x is User { โ
โ return /* your logic */; โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Compiler assumes: โ
โ โ
โ "If this returns true, x is User" โ
โ (Doesn't verify the logic) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Runtime: โ
โ โ
โ Your logic runs โ
โ If it's wrong, narrowing is wrong โ
โ Crashes somewhere else โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Predicate Utilities
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ isString(x): x is string โ
โ isNumber(x): x is number โ
โ isBoolean(x): x is boolean โ
โ isObject(x): x is object โ
โ isNonNullish<T>(x): x is T โ
โ isArrayOf<T>(x, check): x is T[] โ
โ hasProp(obj, key, check): obj is Record... โ
โ isUser(x): x is User โ
โ isAdmin(x): x is Admin โ
โ assertUser(x): asserts x is User โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Concept | Meaning |
|---|---|
| User-defined guard | Function returning a type predicate |
| Predicate syntax | function f(x): x is T |
| Predicate vs boolean | Predicate narrows, boolean doesn’t |
is prefix | Naming convention |
asserts | Assertion function โ throws on failure |
.filter(isT) | Narrows array element type |
| Generics in predicates | isArrayOf<T>, isNonNullish<T> |
| Predicate composition | isA(x) && isB(x) |
| Predicate trust | Compiler trusts the body |
| Boundary validation | Predicates convert unknown to typed |
Key takeaways:
- A user-defined guard is a function returning
x is T - The compiler trusts the predicate โ write the body correctly
- A boolean return doesn’t narrow; a predicate does
- Name predicates with
is,has,canโ signals narrowing intent - Predicates compose โ
isUser(x) && x.role === 'admin' .filter(isT)narrows arrays toT[].every(isT)returns boolean โ doesn’t narrow the array without an outer predicate- Predicates cross callback boundaries โ narrowing persists in closures
asserts x is Tnarrows linearly by throwing on failure- Arrow functions can’t carry an
assertsreturn type โ usefunction - Predicates at boundaries โ JSON, API, DOM โ convert
unknowninto typed values - Combine primitives โ domain predicates โ array predicates โ assertions for layered validation
Remember: Predicates are how you teach TypeScript about custom types. The compiler can’t derive “this is a User” from typeof, instanceof, or in alone โ those distinguish primitives, classes, and shapes but not rich structures. A predicate combines checks and states the resulting type. Write them at boundaries, name them clearly, compose them with && and ||, and wrap them in assertion functions when you want fail-fast validation. The compiler trusts you โ that’s the privilege and the responsibility.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!