User-Defined Type Guards (is)
TypeScript can automatically narrow types through if checks, typeof, instanceof,
and in. But sometimes you need to teach the compiler about a custom check — one that
TypeScript can't figure out on its own. That's where user-defined type guards come in.
A type guard is a function whose return type is a type predicate: paramName is Type.
When such a function returns true, TypeScript narrows the parameter to that type.
The Problem Without Type Guards
Consider this helper function. It checks if a value is a string — but TypeScript doesn't know that, so the narrowing doesn't work:
// Without a type predicate — TypeScript doesn't narrow
function isString(value: unknown): boolean {
return typeof value === 'string'
}
function process(value: string | number) {
if (isString(value)) {
// TypeScript still thinks: value is string | number
// value.toUpperCase() would be an error!
console.log(value) // value is still string | number
}
}Writing a Type Predicate
Change the return type from boolean to paramName is Type. Now TypeScript narrows
automatically when the function returns true.
// With a type predicate — TypeScript narrows correctly
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function process(value: string | number) {
if (isString(value)) {
// TypeScript now knows: value is string
console.log(value.toUpperCase()) // safe!
} else {
// TypeScript knows: value is number
console.log(value.toFixed(2)) // safe!
}
}value is string) must match the parameter name in the function signature. TypeScript uses this to know which variable gets narrowed at the call site.Type Guards for Interface Discrimination
Type predicates shine when distinguishing between interfaces that don't share a class hierarchy. This is the primary use case in real applications.
interface Dog {
breed: string
bark(): void
}
interface Cat {
indoor: boolean
meow(): void
}
type Pet = Dog | Cat
// Check if the pet is a Dog
function isDog(pet: Pet): pet is Dog {
return 'bark' in pet
}
// Check if the pet is a Cat
function isCat(pet: Pet): pet is Cat {
return 'meow' in pet
}
function greet(pet: Pet) {
if (isDog(pet)) {
pet.bark() // TypeScript knows it's a Dog
console.log(`Breed: ${pet.breed}`)
} else if (isCat(pet)) {
pet.meow() // TypeScript knows it's a Cat
console.log(`Indoor: ${pet.indoor}`)
}
}Type Guards with Arrays
Type guards work beautifully with .filter(). Without them, filtering arrays of mixed types
loses type information. With them, TypeScript correctly infers the filtered type.
const items: (string | null | undefined)[] = ['hello', null, 'world', undefined, 'foo']
// Without type guard — TypeScript can't infer the filtered type
const bad = items.filter(x => x !== null && x !== undefined)
// type: (string | null | undefined)[] — still includes null/undefined in type!
// With type predicate — TypeScript knows the result is string[]
function isNonNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
const strings = items.filter(isNonNull)
// type: string[] — correct!
console.log(strings) // ['hello', 'world', 'foo']['hello', 'world', 'foo']
Validating Unknown Data (API Responses)
One of the most important real-world uses of type predicates is validating data that comes
from external sources — JSON from an API, localStorage, user input, etc.
interface User {
id: number
name: string
email: string
}
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
'email' in data &&
typeof (data as any).id === 'number' &&
typeof (data as any).name === 'string' &&
typeof (data as any).email === 'string'
)
}
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`)
const data: unknown = await res.json()
if (!isUser(data)) {
throw new Error('Invalid user data from API')
}
// TypeScript knows: data is User
return data
}Chaining Multiple Type Guards
type StringOrNumberOrBool = string | number | boolean
function isNumber(val: unknown): val is number {
return typeof val === 'number'
}
function isString(val: unknown): val is string {
return typeof val === 'string'
}
function formatValue(val: StringOrNumberOrBool): string {
if (isNumber(val)) {
return val.toFixed(2) // val: number
}
if (isString(val)) {
return val.trim().toUpperCase() // val: string
}
// val: boolean (only possibility left)
return val ? 'Yes' : 'No'
}Common Mistakes
Mistake | Problem | Correct approach |
|---|---|---|
Lying in a type guard | Returning true for wrong type causes runtime crashes | Always validate all required properties |
Using any in guard body carelessly | Bypasses type checking inside the guard | Cast to Record or use in checks |
Forgetting null check | "obj is null" still passes "object" typeof check | Check !== null before property access |
Asserting nested types shallowly | Only checks outer shape, misses nested structure | Validate all required nested properties |
TypeScript 5.5: Inferred Type Predicates
TypeScript 5.5 introduced inferred type predicates — in simple cases, TypeScript can automatically infer the predicate type from the function body.
// TypeScript 5.5+ — predicate inferred automatically
function isDefinedString(x: string | undefined) {
return x !== undefined // return type inferred as: x is string
}
const values = ['a', undefined, 'b', undefined, 'c']
const defined = values.filter(isDefinedString)
// type: string[] — TypeScript 5.5+ infers this without explicit predicatePractical: Redux Action Type Guards
// Common in Redux or event-based architectures
interface LoadAction {
type: 'LOAD'
url: string
}
interface SuccessAction {
type: 'SUCCESS'
data: unknown[]
}
interface ErrorAction {
type: 'ERROR'
message: string
}
type AppAction = LoadAction | SuccessAction | ErrorAction
function isLoadAction(action: AppAction): action is LoadAction {
return action.type === 'LOAD'
}
function isErrorAction(action: AppAction): action is ErrorAction {
return action.type === 'ERROR'
}
function handleAction(action: AppAction) {
if (isLoadAction(action)) {
console.log(`Loading from ${action.url}`)
} else if (isErrorAction(action)) {
console.log(`Error: ${action.message}`)
} else {
console.log(`Got ${action.data.length} items`)
}
}