CtrlK
BlogDocsLog inGet started
Tessl Logo

typescript-advanced-type-guards

Runtime type guards, narrowing, and exhaustiveness checking for TypeScript — typeof/instanceof/in guards, custom and generic type predicates (value is T), discriminated union narrowing, assertion functions (asserts value is T), branded/nominal types validated at runtime, and exhaustive switch statements with a never guard. Use when replacing an unsafe `as` assertion with a real runtime check, fixing "Object is possibly undefined", modeling impossible states out of existence, writing an `is*` predicate, or making a switch over a union fail to compile when a case is missing.

72

Quality

89%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

TypeScript Type Guards

Runtime type guards and narrowing: the compile-time type system backed by an actual runtime check, so a type guarantee is real rather than asserted.

Mindset

A type guard is a promise the compiler will hold you to only if the runtime check underneath it is honest. Never reach for any or an unchecked as assertion as a shortcut past a type error — both disable the exact safety net a guard exists to provide, and the error they suppress almost always points at a real design gap. Prefer narrowing a value before use over asserting its type after the fact: if (typeof x !== "string") throw ... gives the compiler a real fact to reason from; x as string gives it nothing but your word. Verify a narrowing actually narrows by re-running tsc --noEmit after the change — a guard that looks correct can still fail to narrow if its predicate doesn't match the shape TypeScript expects. Every union that models a real set of states deserves an exhaustive switch with a never-typed default branch, so adding a new state without updating every handler is a compile error, not a silent gap.

When to Apply

Use this skill when:

  • Replacing an unsafe as assertion with a runtime check that narrows for real
  • Writing a custom type predicate (function is*(x): x is T)
  • Modeling impossible field combinations out of existence with a discriminated union
  • Adding an assertion function (asserts value is T) to validate and narrow in one call
  • Creating branded/nominal types with a validating factory function
  • Adding exhaustiveness checking to a switch over a union so new variants can't be silently unhandled
  • Fixing "Object is possibly 'undefined'" without reaching for a non-null assertion (!)

Use When

  • "How do I fix this TypeScript compile error without an as assertion?"
  • "How do I check the shape of an unknown value at runtime?"
  • "How do I make my switch statement error if I add a new case and forget to handle it?"
  • "How do I stop two IDs of the same primitive type from being mixed up?"

Scope

In Scope

  • typeof, instanceof, in, truthiness, and equality narrowing.
  • Custom and generic type predicate functions (value is T).
  • Discriminated union narrowing and exhaustiveness checking with never.
  • Assertion functions (asserts condition, asserts value is T).
  • Branded/nominal types validated by a runtime type guard.

Out of Scope

  • The type-system mechanics behind a union or conditional type itself — see the sibling typescript-type-system skill.
  • Zod-based schema validation as a library choice — see runtime-validation.md in the sibling typescript-practices skill; this skill covers hand-written predicates and assertion functions.
  • General design patterns (builder, state machine, event emitter) that use guards — see the sibling typescript-design-patterns skill.

When NOT to Use

Do not use this skill for compiler configuration (tsconfig, strict-mode flags — see typescript-compiler-config) or for utility-type mechanics like Pick/Omit/ReturnType (see typescript-utility-types). Do not use it as a substitute for actually running tsc --noEmit — the reference files teach the pattern, but the compiler is the source of truth on whether a guard actually narrows.

Quick Commands

Type Check

npx tsc --noEmit

Find Unsafe Patterns

rg -n "\\bany\\b|@ts-ignore| as " src

Quick Fixes

"Object is possibly 'undefined'"

// BAD — non-null assertion hides the real problem
function getHost(config: Config): string {
  return config.host!.toUpperCase();
}

// GOOD — guard before access
function getHost(config: Config): string {
  if (config.host === undefined) throw new Error("host is required");
  return config.host.toUpperCase();
}

"Type X is not assignable to type Y"

// BAD — forces an incompatible assignment
const id = getValue() as string;

// GOOD — narrow first, then assign
const raw = getValue();
if (typeof raw !== "string") throw new TypeError("Expected string");
const id = raw; // TypeScript now knows id: string

Anti-Patterns

NEVER use any as a default escape hatch

WHY: any disables type checking and hides the design bug the error was pointing at.

BAD:

function process(data: any) {
  return data.value;
}

GOOD:

function process<T extends { value: unknown }>(data: T) {
  return data.value;
}

NEVER silence type errors with unchecked assertions

WHY: an as assertion bypasses compiler safety without a matching runtime guarantee.

BAD:

const id = input as string;

GOOD:

if (typeof input !== "string") throw new TypeError("Expected string");
const id = input;

NEVER use numeric enum for a discriminated union tag

WHY: numeric enums generate a reverse mapping at runtime (Direction[0] === "Up"), inflate bundle size, and let any number be assigned where the enum type is expected — none of which a string-literal union has.

BAD:

enum Status { Pending, Active, Closed }
function handle(status: Status) { /* Status.Pending accepts any number */ }

GOOD:

type Status = "pending" | "active" | "closed";
function handle(status: Status) { /* only the three literal strings are valid */ }

NEVER let a discriminant literal widen

WHY: let and object-literal inference widen "success" to string, which breaks exhaustive narrowing on the union's discriminant even though the runtime value never changes.

BAD:

let kind = "success"; // inferred as string, not "success"
const result = { kind, data: 42 }; // result.kind: string — narrowing fails

GOOD:

const kind = "success" as const;
const result = { kind, data: 42 } as const; // result.kind: "success"

NEVER leave a switch over a union without an exhaustiveness check

WHY: without a never-typed default branch, adding a new union variant compiles silently and the new case is unhandled at runtime.

BAD:

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.size ** 2;
    // no default — adding "triangle" to Shape compiles without warning
  }
}

GOOD:

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.size ** 2;
    default:
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

References

FileCovers
references/basic-guards.mdtypeof, instanceof, in, truthiness, and equality narrowing
references/generic-guards.mdReusable generic type predicates (isArrayOf, hasProperty, isInstance)
references/discriminated-unions.mdTagged unions, multiple discriminants, state-machine narrowing
references/exhaustiveness-checking.mdThe never exhaustiveness pattern across switch, if/else, and pattern matching
references/assertion-functions.mdasserts condition and asserts value is T functions
references/branded-types-guards.mdNominal types validated by a runtime predicate, multi-level brands, unwrap utilities
  • TypeScript Handbook: Narrowing
  • TypeScript Handbook: Assertion Functions
Repository
pantheon-org/tekhne
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.