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 2026Revised13 Oct 2026Reading11 minVersionv1.2ConfidenceHigh
§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 never anticipated, the field reached for is undefined and 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
Copy for paste
§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 arrive.
  • as retreats to the witness-pattern carve-out and is no longer 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

Narrowing is older than TypeScript and broader than any one language. Tim Freeman and Frank Pfenning’s 1991 Refinement Types for ML introduced the academic form: subtypes of a datatype that the compiler infers and checks, catching at compile time the errors plain ML types let through.4 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.5

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.6 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.7 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 hold the rest.

§2

Quotes

TypeScript follows possible paths of execution that our programs can take to analyze the most specific possible type of a value at a given position. It 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

Any function that returns a type predicate is a ”user-defined type guard.”

Dan Vanderkam · The Hidden Side of Type Predicates (2024)

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

The resulting system of refinement types preserves desirable properties of ML such as decidability of type inference, while at the same time allowing more errors to be detected at compile-time.

Tim Freeman & Frank Pfenning · Refinement Types for ML (1991)
§3

Evidence

19 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.
  1. 06
    Dan Vanderkam · 2019
    Vanderkam’s book-length argument. The narrowing chapter is the canonical industrial reference for the rule.
  2. 07
    Dan Vanderkam · 2019
    The companion argument: an over-broad type forces casts at the boundary that a narrower type would refuse. Pairs with T1.
  3. 08
    Colin McDonnell · 2020+
    The dominant ecosystem expression of the principle. Schema is the validator and the type. SafeParse returns a discriminated result. The type cannot drift from the validator.
  4. 09
    Fabian Hiller · 2023+
    The tree-shakeable alternative to Zod. Same discipline, smaller bundle. Useful where schema-validation cost is load-bearing on the client.
  5. 10
    Giulio Canti · 2017+
    The earlier functional-programming statement of the same idea. Established the pattern the rest of the ecosystem inherited.
  6. 11
    Tim Freeman & Frank Pfenning · 1991
    The academic origin of refinement types: subtypes of ML datatypes that the compiler infers and checks, so more errors are caught at compile time while type inference stays decidable.
  7. 12
    Niki Vazou et al. · 2014+
    The modern academic continuation of Freeman and Pfenning’s line. Refinement types as a production-language feature. Predicates the compiler verifies.
  8. 13
    Rust language team · 2010+
    The pattern-matching idiom in a non-TS language. Instructive precedent: the compiler refuses an inexhaustive match by default.
  9. 14
    Scala language team · 2004+
    The mainstream JVM expression of pattern matching. Sealed traits + exhaustive match becomes the structural form of the rule.
  10. 15
    typescript-eslint maintainers · 2019+
    The lint enforcement of the exhaustiveness pattern. Catches the missing case in a switch on a discriminated union.
  11. 16
    Daniel Rosenwasser · 2021
    The language is doing more of the narrowing work for you, eating a class of casts that used to require a hand-written predicate.
  12. 17
    Scott Wlaschin · 2013
    The F# essay that shaped the modern industrial discipline. Discriminated unions + smart constructors as the structural refusal of as.
  13. 18
    Samuel Colvin · 2017+
    The Python equivalent of Zod. Boundary validation with type inference: the parsed value is the typed value.
  14. 19
    TypeScript team · 2020+
    Assertion functions (asserts x is Foo) extend predicate narrowing to functions that throw on failure: the throw-narrowing pattern.

Nineteen 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 hold 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

Before the cast asserts a shape the runtime never checked.

Filehedgehog-reading.ts
function handleSensorEvent(value: unknown): void {
  const reading = value as HedgehogReading;
  recordWeight(reading.weight); // crashes if value is null
}
Prefer

After the predicate verifies each field; the type follows the proof.

Filehedgehog-reading.ts
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: the compile error you wanted when a new variant arrives.
@typescript-eslint/no-unnecessary-type-assertiontypescript-eslintas casts the compiler can already prove: usually leftover noise that hides the cast beside it.
@typescript-eslint/no-non-null-assertiontypescript-eslintthe ! operator: 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: returns a discriminated result the caller must handle.
v.parse / v.safeParseValibotthe same discipline with a tree-shakeable footprint: useful when bundle size is load-bearing on the client.
t.decodeio-tsboundary input through a functional decoder: the older ecosystem alternative for fp-ts shops.
eslint.config.mjsconfiguration snippet
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';

export default defineConfig({
  files: ['**/*.{ts,tsx}'],
  extends: [tseslint.configs.base],
  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
---
alwaysApply: true
---

## 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.1 The TypeScript 5.5 inferred-type-predicates release notes hold 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.2 The stronger 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.3 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.2 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 worth writing 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 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. [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. [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.
Disagree? Found a hole in the argument? Take issue with this tenet →
Last revised: 2026-10-13