Type Guards
Prove the type, don't assert it.
A cast is a promise without a check. A guard is a check that becomes a promise. The shape that enters the code at JSON.parse is unknown until something proves otherwise, and the proof is the predicate, not the assertion.
Opinion
I've shipped enough as Response bugs to know what the cast is hiding. The compiler shrugs; the runtime returns a shape the code did not expect; the field reached for is undefined; the page renders blank. A guard would have caught it on the line the data arrived. as is a lie addressed to the compiler so the build will pass; the type predicate is the truth addressed to the runtime so the user does not see the crash. The pair only works when the guard is written.
The compile-time rule and the runtime rule are sister tenets, not duplicates. See T2 No Escape Hatches: T2 forbids the cast at compile time. T3 names what gets written instead. Where the boundary is a JSON payload, that is a Zod schema with safeParse. Where it is a discriminated union, that is a switch with an exhaustive default: assertNever(x). Where it is a DOM event, that is a typed predicate. Each one moves the proof from the comment above the cast to the function that returns x is Foo.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Prove the type, don't assert it. Anywhere data crosses a runtime boundary (JSON.parse, localStorage, postMessage, route input, error catch, library boundary), validate the shape with a type predicate, a discriminated-union narrowing, or a schema (Zod / Valibot / io-ts). `as` is a lie you tell the compiler. /tenet/type-guards/T3
AI eyes only
Rule: prove the type, do not assert it.
Reject: value as Foo without a runtime predicate. Reject: trusting external JSON without parsing. Reject: type assertions used as the cheaper alternative to narrowing.
Generate: a runtime schema (Zod / Valibot) at every external boundary. Infer the static type from the schema (z.infer<typeof FooSchema>). For internal narrowing, write a type guard function (is Foo).
Diagnostic: every cast must have a paired predicate. Every external input must pass a schema before reaching the domain.
Why?
- Failures land at the boundary, not three modules deep. A bad payload fails at
safeParse; the runtime stack trace points at the entry point, not at a JSX render in a downstream component. - Schema-inferred types keep the validator and the type in the same artefact. Drift is impossible — the type is the schema, evaluated.
- Discriminated-union narrowing turns a missed case into a compile error.
default: assertNever(x)is the safety net that catches new variants the day they ship. asretreats to the witness-pattern carve-out, not the daily workhorse. Most legacy casts dissolve under TypeScript 4.4+ control-flow analysis once you stop reaching for the cast as the first move.- Pairs with T1 Domain-Driven Types. Branded primitives with smart constructors put the validation in the constructor; the guard is the only way the brand can attach.
- Schema-driven validation is structure the agent can imitate. A Zod schema is a template the model can fill in; a one-off type predicate is invention the model often gets wrong.
- Predicates and schemas are testable with property-based generators. The cast was untestable; the guard is the spec.
Origins
The runtime-narrowing idea is older than TypeScript and broader than any one language. Phil Wadler’s 1991 Refinement Types for ML introduced the academic form: types that carry a runtime predicate, narrowed by checks the compiler could verify.4Refinement Types for ML (1991). The academic origin of the runtime-narrowing idea; types as predicates the compiler can verify. The intellectual ancestor of every modern guard library. Niki Vazou and Ranjit Jhala extended the line to Liquid Haskell and the F-star / Liquid family that survives in production research today.
The pattern-matching tradition arrived through ML and OCaml in the 1980s, became the spine of Haskell, and is the centre of every functional language since. Scala’s pattern matching, Rust’s match, OCaml’s exhaustive match — all are the same idea: validate the shape before reading the value, and let the compiler enforce coverage. The TypeScript-specific incarnation is user-defined type predicates and discriminated-union narrowing, framed in the handbook’s Narrowing chapter as the load-bearing technique for moving an unknown into a typed shape.5TypeScript Handbook, Narrowing chapter. The handbook’s framing of type predicates and discriminated-union narrowing as the load-bearing technique for moving an unknown into a typed shape.
The schema-validation tradition is the industrial form. Giulio Canti’s 2017 io-ts brought the io-as-schema discipline to the TypeScript ecosystem; Colin McDonnell’s 2020 Zod made it ergonomic enough for general adoption.6Zod (2020+). The library that made schema-first validation with type inference ergonomic enough for general adoption. The dominant industrial form of the rule. Both libraries make the principle concrete: write a schema; the type is inferred from the schema; the validator and the type are the same artefact, so they cannot drift. Zod is now the working surface for almost every TS team; Valibot is the modular alternative that argues for tree-shakeable schemas.
The TypeScript team itself has been moving the rule from a manual discipline into a language feature. TypeScript 4.4 introduced control-flow analysis of aliased conditions, eating a class of casts that used to require a hand-written predicate.7TypeScript 4.4 release notes (Microsoft Developer Blogs, 2021). Control flow analysis of aliased conditions: the language eats a class of casts that used to require a hand-written predicate. TypeScript 5.5’s inferred type predicates extend the same line: where the compiler can prove the predicate, you no longer have to write it. The rule has shrunk in surface area and sharpened in principle: validate the runtime data, write the predicate the compiler cannot infer, and let the language carry the rest.
Quotes
TypeScript looks at these special checks (called type guards) and assignments, and the process of refining types to more specific types than declared is called narrowing.
It's especially important to consider how a function is going to know about its narrowed types. The most common way to do this is with type predicates, but the others also have their place.
Zod is designed to be as developer-friendly as possible. The goal is to eliminate duplicative type declarations. With Zod, you declare a validator once and Zod will automatically infer the static TypeScript type.
We can refine the standard types of ML by introducing predicates that capture additional properties of values; the type checker then verifies these properties statically.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01TypeScript Handbook — NarrowingSupportsThe primary reference. Covers typeof, instanceof, in, equality, user-defined type predicates, and discriminated unions in one place.
- 02The canonical syntax explanation for x is Foo. The shape every other reference cites.
- 03The most common type-guard pattern in idiomatic TS: a literal-typed kind field plus a switch becomes the runtime contract for free.
- 04The safety net: assertNever(x) on a default case errors when a new variant is added but the switch is not updated.
- 05The language now infers a class of predicates that used to require x is Foo by hand. Shrinks the surface where the rule asks for ceremony; the principle is unchanged.
Sixteen sources, the supports clustered around the TypeScript Handbook's narrowing, type-predicate, discriminated-union and exhaustiveness pages, plus Daniel Rosenwasser's TypeScript 5.5 inferred-predicates release notes. Schema-library and field essays sit further down. The qualifiers carry the TypeScript 5.5 caveat: the language now infers a class of predicates the rule used to require by hand. The opposers are mostly performance arguments and the “types are documentation” school that under-counts the runtime side.
Examples
// Before: the cast asserts a shape the runtime never checked.function handleSensorEvent(value: unknown): void { const reading = value as HedgehogReading; recordWeight(reading.weight); // crashes if value is null}
// After: the predicate verifies each field; the type follows the proof.function isHedgehogReading(value: unknown): value is HedgehogReading { if (typeof value !== "object" || value === null) return false; const r = value as Record<string, unknown>; return typeof r.weight === "number" && typeof r.tagId === "string";}function handleSensorEvent(value: unknown): void { if (isHedgehogReading(value)) recordWeight(value.weight);}
// Before: the cast asserts a shape the runtime never checked.function handleSensorEvent(value: unknown): void { const reading = value as HedgehogReading; recordWeight(reading.weight); // crashes if value is null}
// After: the predicate verifies each field; the type follows the proof.function isHedgehogReading(value: unknown): value is HedgehogReading { if (typeof value !== "object" || value === null) return false; const r = value as Record<string, unknown>; return typeof r.weight === "number" && typeof r.tagId === "string";}function handleSensorEvent(value: unknown): void { if (isHedgehogReading(value)) recordWeight(value.weight);}
// Before: trust the wire shape; reach for .weight on whatever arrived.function handleSensorEvent(value) { recordWeight(value.weight); // crashes if value is null}
// After: a predicate verifies each field; the call only fires on a real reading.function isHedgehogReading(value) { if (typeof value !== "object" || value === null) return false; return typeof value.weight === "number" && typeof value.tagId === "string";}function handleSensorEvent(value) { if (isHedgehogReading(value)) recordWeight(value.weight);}
# Before: cast asserts a shape the runtime never checked.def handle_sensor_event(value: object) -> None: reading = cast(HedgehogReading, value) record_weight(reading.weight) # crashes if value is None
# After: a TypeGuard predicate proves the shape; the type follows the proof.def is_hedgehog_reading(value: object) -> TypeGuard[HedgehogReading]: if not isinstance(value, dict): return False return isinstance(value.get("weight"), float) and isinstance(value.get("tag_id"), str)def handle_sensor_event(value: object) -> None: if is_hedgehog_reading(value): record_weight(value["weight"])
// Before: cast asserts a shape the runtime never checked.void handleSensorEvent(Object value) { HedgehogReading reading = (HedgehogReading) value; recordWeight(reading.weight()); // throws ClassCastException if value is wrong shape}
// After: pattern matching binds only when every field has the right type.record HedgehogReading(String tagId, double weight) {}void handleSensorEvent(Object value) { if (value instanceof HedgehogReading reading) { recordWeight(reading.weight()); }}
// Before: assertion asserts a shape the runtime never checked.function handleSensorEvent(mixed $value): void { /** @var HedgehogReading $reading */ $reading = $value; recordWeight($reading->weight); // fatal error if value is not an object}
// After: a phpstan-assert predicate verifies each field; the call only fires on a real reading./** @phpstan-assert-if-true HedgehogReading $value */function isHedgehogReading(mixed $value): bool { if (!is_array($value)) return false; return is_string($value["tag_id"] ?? null) && is_float($value["weight"] ?? null);}function handleSensorEvent(mixed $value): void { if (isHedgehogReading($value)) { recordWeight($value["weight"]); }}
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @typescript-eslint/switch-exhaustiveness-check | typescript-eslint | missing cases in a switch on a discriminated union — the compile error you wanted when a new variant ships. |
| @typescript-eslint/no-unnecessary-type-assertion | typescript-eslint | as casts the compiler can already prove — usually leftover noise that hides the real cast next to it. |
| @typescript-eslint/no-non-null-assertion | typescript-eslint | the ! operator — the same crime as as with extra steps. |
| @typescript-eslint/consistent-type-assertions | typescript-eslint | as on object literals (the silent any-bypass for fresh values) and inconsistent assertion styles. |
| z.object().safeParse | Zod | boundary input that does not match the declared schema — returns a discriminated result the caller must handle. |
| v.parse / v.safeParse | Valibot | the same discipline with a tree-shakeable footprint — useful when bundle size is load-bearing on the client. |
| t.decode | io-ts | boundary input through a functional decoder — the older ecosystem alternative for fp-ts shops. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: { project: './tsconfig.json' },
},
rules: {
'@typescript-eslint/switch-exhaustiveness-check': ['error', {
considerDefaultExhaustiveForUnions: false,
}],
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/consistent-type-assertions': ['error', {
assertionStyle: 'as',
objectLiteralTypeAssertions: 'never',
}],
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
}
});Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @typescript-eslint/switch-exhaustiveness-check | typescript-eslint | missing cases in a switch on a discriminated union — the compile error you wanted when a new variant ships. |
| @typescript-eslint/no-unnecessary-type-assertion | typescript-eslint | as casts the compiler can already prove — usually leftover noise that hides the real cast next to it. |
| @typescript-eslint/no-non-null-assertion | typescript-eslint | the ! operator — the same crime as as with extra steps. |
| @typescript-eslint/consistent-type-assertions | typescript-eslint | as on object literals (the silent any-bypass for fresh values) and inconsistent assertion styles. |
| z.object().safeParse | Zod | boundary input that does not match the declared schema — returns a discriminated result the caller must handle. |
| v.parse / v.safeParse | Valibot | the same discipline with a tree-shakeable footprint — useful when bundle size is load-bearing on the client. |
| t.decode | io-ts | boundary input through a functional decoder — the older ecosystem alternative for fp-ts shops. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: { project: './tsconfig.json' },
},
rules: {
'@typescript-eslint/switch-exhaustiveness-check': ['error', {
considerDefaultExhaustiveForUnions: false,
}],
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/consistent-type-assertions': ['error', {
assertionStyle: 'as',
objectLiteralTypeAssertions: 'never',
}],
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
}
});Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| schema.parse | Zod | boundary input that does not match the schema — throws on failure, narrowing the JS-level shape on success. |
| Object.hasOwn / typeof / in checks | JavaScript runtime | structural assumptions that the cast version of the same code would have hidden — honest predicates the next reader can audit. |
| JSDoc @returns {x is Foo} | TypeScript via @ts-check | the type-predicate pattern in JSDoc-annotated JS files — lets the editor narrow on a runtime check. |
| switch + assertNever() | JavaScript convention | the same exhaustiveness pattern, expressed as a runtime throw — without TS, the failure happens in test rather than at compile. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: { project: './tsconfig.json' },
},
rules: {
'@typescript-eslint/switch-exhaustiveness-check': ['error', {
considerDefaultExhaustiveForUnions: false,
}],
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/consistent-type-assertions': ['error', {
assertionStyle: 'as',
objectLiteralTypeAssertions: 'never',
}],
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
}
});Apply these rules in pyproject.toml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| TypeGuard / TypeIs | mypy | user-defined narrowing functions that the type checker uses to refine the inferred type. The Python form of x is Foo. |
| match statements + exhaustiveness | Python language | missing cases in a structural pattern match — mypy will flag a fall-through where the union has been narrowed. |
| BaseModel.model_validate | Pydantic | boundary input that does not match the declared model — raises ValidationError; the parsed value is the typed value. |
| TypeAdapter.validate_python | Pydantic | boundary validation for non-model types (dict, list, primitive containers) without forcing a wrapper class. |
| ANN401 | Ruff | Any annotations on functions that should have been TypeGuard / TypeIs / a precise return type. |
| TCH001 / TCH002 | Ruff | imports that should be moved into a TYPE_CHECKING block — keeps the runtime type narrow. |
pyproject.tomlconfiguration snippet
[tool.mypy]
strict = true
warn_unused_ignores = true
[tool.ruff.lint]
select = [
"ANN",
"TCH",
"RET",
]Apply these rules in build.gradle. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| sealed interfaces + switch patterns | Java language | missing cases in a switch on a sealed type — the compiler refuses the inexhaustive match. |
| instanceof pattern | Java language | the cast that used to follow an instanceof check — pattern instanceof binds the narrowed reference in one step. |
| Bean Validation constraints | Hibernate Validator | boundary input that does not match the declared constraints (@NotNull, @Size, @Email, @Pattern). Throws ConstraintViolationException; the validated bean is the typed bean. |
| @JsonTypeInfo + @JsonSubTypes | Jackson | discriminated-union JSON payloads — Jackson reads the discriminator and constructs the correct subclass before any field is read. |
| Records + compact constructors | Java language | boundary value objects that need invariant-checks at construction — the compact constructor throws before the record exists. |
| -Xlint:all + -Werror | javac | unchecked warnings, fall-through, and cast-related lints elevated to compile errors. |
build.gradleconfiguration snippet
plugins {
id 'java'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0'
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}
tasks.named('compileJava') {
options.compilerArgs += [
'-Xlint:all',
'-Werror',
]
}Apply these rules in psalm.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| match expressions + enum cases | PHP language | missing cases in a match on a backed enum — PHP throws UnhandledMatchError at runtime; Psalm flags it at static-analysis time. |
| @psalm-assert annotations | Psalm | missing assertion documentation on a function that narrows a parameter — the PHP form of x is Foo. |
| @phpstan-assert annotations | PHPStan | the same narrowing discipline in PHPStan-annotated code — supports the same predicate forms with a different docblock prefix. |
| RedundantConditionGivenDocblockType | Psalm | runtime checks that the type system already proves — usually a sign the predicate has rotted out of sync with the type. |
| Validator constraints | Symfony Validator | boundary input that does not match the declared constraints (NotBlank, Email, Regex). The validated value is the typed value. |
| TypeDoesNotContainType | Psalm | comparisons that can never be true — a sign that a guard is testing the wrong shape. |
psalm.xmlconfiguration snippet
<?xml version="1.0"?>
<psalm
errorLevel="1"
findUnusedBaselineEntry="true"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
>
<projectFiles>
<directory name="src"/>
</projectFiles>
<issueHandlers>
<RedundantConditionGivenDocblockType errorLevel="error"/>
<RedundantCondition errorLevel="error"/>
<DocblockTypeContradiction errorLevel="error"/>
<TypeDoesNotContainType errorLevel="error"/>
</issueHandlers>
</psalm>AI rules
.cursor/rules/t3-type-guards.mdc---
description: Prickles T3 — Type Guards
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles T3 — Type Guards
Where data crosses a runtime boundary (`JSON.parse`, `localStorage`, `postMessage`, route input, error catch, library boundary), validate the shape with a guard before the value flows further.
Prefer user-defined type predicates (`x is Foo`), discriminated-union narrowing, and schema-driven guards (Zod, Valibot, io-ts) over `as` casts.
Use `switch` on a discriminator with an exhaustive `default: assertNever(x)` so the compiler enforces full case coverage.
If you must write `as`, prefer rewriting the surrounding code so the compiler can prove the type itself. TypeScript 4.4 control-flow analysis and 5.5 inferred predicates eliminate most legacy uses.
The guard is the runtime contract. The compile-time type lives or dies by what the guard accepts.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
The strongest steelman is the performance argument. A schema parse on every boundary call adds measurable cost; a hand-written predicate is faster but easy to drift; a cast is free at runtime and the team has historically chosen free-but-wrong over slow-but-right.1Simple Made Easy (Strange Loop, 2011). Hickey’s argument that every guard is added complexity the application now owns. The deep steelman against zero-tolerance validation; the reply is that boundary validation is incomplexity, not added complexity. The TypeScript 5.5 inferred-type-predicates release notes carry a related counter: the language is doing more of the narrowing work, so the rule “always write the guard” risks insisting on ceremony the compiler would have done on its own.2TypeScript 5.5 release notes (Microsoft Developer Blogs, 2024). Inferred Type Predicates: the language now infers a class of predicates that used to require x is Foo by hand. The principle is unchanged; the surface area shrinks. The deeper steelman is Hickey's: every guard is added complexity the application now owns; in some shapes, the runtime cost of the wrong assumption is smaller than the maintenance cost of the right validator.
Counter-argument retort
The performance argument is real and answered by the shape of the validation. A Zod safeParse at a route boundary runs once per request; the cost is a few hundred microseconds and bounded by the schema’s own size. The same schema rejected a thousand malformed inputs before they reached your domain code; the bug it caught at the boundary is the bug that did not page anyone at 3am.3Effective TypeScript (O’Reilly, 2019), Item 22. Narrowing turns the runtime check into compile-time evidence; the alternative is a cast that papers over the same gap without the runtime payoff. Where the validation cost is genuinely load-bearing — a hot path inside the type system, a per-row check on a million-row stream — the right answer is a hand-rolled predicate with a property-based test, not a cast.
The TypeScript 5.5 caveat is correct and does not retire the rule. Inferred type predicates shrink the surface where you write x is Foo by hand; they do not shrink the surface where data enters the system as unknown and has to be validated.2TypeScript 5.5 release notes (Microsoft Developer Blogs, 2024). Inferred Type Predicates: the language now infers a class of predicates that used to require x is Foo by hand. The principle is unchanged; the surface area shrinks. The rule moved one rung up: write the predicate the compiler couldn’t infer; let it infer where it can. The principle — validate the runtime data — is unchanged.
The Hickey counter is the strongest one, and the answer is the discipline. A guard that earns its keep does three jobs: it proves the type at runtime, it documents the boundary in code, and it gives the next reader a predicate to test against. A cast does none of those. Where the runtime cost of the wrong assumption is smaller than the maintenance cost of the right validator — a pure UI state machine, an in-process function over typed inputs — the rule does not apply because there is no boundary to validate. T3 is about boundaries, not about every type narrowing in the codebase.
The genuine residue is small. Where the TypeScript flow analysis can prove the narrowing, the rule asks for nothing. Where it cannot, the rule asks for a predicate, a discriminated union with an exhaustive switch, or a schema. The cast survives only as a witness pattern — an explicit acknowledgement that a runtime invariant is established elsewhere (parser output already validated, an ID format checked at construction). Even there, see T1 Domain-Driven Types: a branded primitive with a smart-constructor would have removed the witness.
Notes
- [1]Rich Hickey — Simple Made Easy (Strange Loop, 2011). Hickey’s argument that every guard is added complexity the application now owns. The deep steelman against zero-tolerance validation; the reply is that boundary validation is incomplexity, not added complexity.
- [2]Daniel Rosenwasser — TypeScript 5.5 release notes (Microsoft Developer Blogs, 2024). Inferred Type Predicates: the language now infers a class of predicates that used to require x is Foo by hand. The principle is unchanged; the surface area shrinks.
- [3]Dan Vanderkam — Effective TypeScript (O’Reilly, 2019), Item 22. Narrowing turns the runtime check into compile-time evidence; the alternative is a cast that papers over the same gap without the runtime payoff.