Case file — T3

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.

ByAdam LewisPublished3 May 2026Reading11 minVersionv1.0ConfidenceHigh
§0b

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
§0c

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.

§0d

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.
  • as retreats 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.
The receipts
Origins, quoted passages, evidence, the strongest counter-argument and the reply.
§1

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.4Phil WadlerRefinement 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 teamTypeScript Handbook, Narrowing chapter. The handbook&rsquo;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.6Colin McDonnellZod (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.7Anders HejlsbergTypeScript 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.

§2

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.

TypeScript Handbook · Narrowing chapter

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.

Dan Vanderkam · Effective TypeScript (2019), Item 22

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.

Colin McDonnell · Zod documentation

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.

Phil Wadler · Refinement Types for ML (1991)
§3

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.

  1. 01
    TypeScript team · 2020+
    The primary reference. Covers typeof, instanceof, in, equality, user-defined type predicates, and discriminated unions in one place.
  2. 02
    TypeScript team · 2020+
    The canonical syntax explanation for x is Foo. The shape every other reference cites.
  3. 03
    TypeScript team · 2020+
    The most common type-guard pattern in idiomatic TS: a literal-typed kind field plus a switch becomes the runtime contract for free.
  4. 04
    TypeScript team · 2020+
    The safety net: assertNever(x) on a default case errors when a new variant is added but the switch is not updated.
  5. 05
    Daniel Rosenwasser · 2024
    The 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.

§4

Examples

Viewing: TypeScript.
Avoid
Filehedgehog-reading.ts
// 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}
Prefer
Filehedgehog-reading.ts
// 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);}
§4b

Enforcement

Viewing: TypeScript.

Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.

RuleToolCatches
@typescript-eslint/switch-exhaustiveness-checktypescript-eslintmissing cases in a switch on a discriminated union &mdash; the compile error you wanted when a new variant ships.
@typescript-eslint/no-unnecessary-type-assertiontypescript-eslintas casts the compiler can already prove &mdash; usually leftover noise that hides the real cast next to it.
@typescript-eslint/no-non-null-assertiontypescript-eslintthe ! operator &mdash; the same crime as as with extra steps.
@typescript-eslint/consistent-type-assertionstypescript-eslintas on object literals (the silent any-bypass for fresh values) and inconsistent assertion styles.
z.object().safeParseZodboundary input that does not match the declared schema &mdash; returns a discriminated result the caller must handle.
v.parse / v.safeParseValibotthe same discipline with a tree-shakeable footprint &mdash; useful when bundle size is load-bearing on the client.
t.decodeio-tsboundary input through a functional decoder &mdash; 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',
  }
});
§4c

AI rules

File.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.

§5

Counter-argument

Counter

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.1Rich HickeySimple Made Easy (Strange Loop, 2011). Hickey&rsquo;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.2Daniel RosenwasserTypeScript 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.

§6

Counter-argument retort

Reply

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.3Dan VanderkamEffective TypeScript (O&rsquo;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.2Daniel RosenwasserTypeScript 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.

§7

Notes

  1. [1]Rich HickeySimple Made Easy (Strange Loop, 2011). Hickey&rsquo;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. [2]Daniel RosenwasserTypeScript 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. [3]Dan VanderkamEffective TypeScript (O&rsquo;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.
Disagree? Found a hole in the argument? Take issue with this tenet →
Last revised: 2026-04-27