type-safety

Installation
SKILL.md

Type Safety

Playbook for moving invariants from runtime checks into the type system. Six techniques that work together to make illegal states unrepresentable and catch bugs at compile time instead of test time.

This is NOT a general TypeScript style guide. These techniques apply to:

  • Domain model design
  • SDK / library public APIs
  • Discriminated unions with mutually exclusive shapes
  • Entity ID modeling (task IDs, user IDs, etc.)
  • Boundary hardening (input validation, DB reads, external API responses)

They do NOT belong in:

  • Application glue code (route handlers, UI components)
  • Scripts, migrations, one-off tooling
  • Code where a runtime check is clearer and equally safe
  • Codebases without an existing type discipline to build on

If you reach for these patterns in application code, you are probably over-engineering. A string is usually fine. A .parse() call at the boundary is usually fine. Reach for these techniques only when the type system can eliminate a class of bug that runtime checks keep missing.


Core stance

Make illegal states unrepresentable. If a bug can be prevented at compile time, the type system is the cheapest place to enforce it — no tests to write, no runtime overhead, no possibility of the check being skipped.

Runtime validation at trust boundaries. Type-level enforcement everywhere else. Parse once at the edge (MCP input, DB read, external API response), then let the branded/narrowed type flow through the rest of the system unchecked. This is the Parse, don't validate principle — the parser returns a type that carries proof of validation.

Deleting a cast is evidence, not risk. If you can remove an as T without a type error, the cast was unnecessary — the types already lined up. If removing it causes an error, you've found a real type hole that the cast was hiding. Either outcome is progress. Treat every as in production code as an admission of defeat and an audit target.


The six techniques

Each technique has its own reference file with patterns, examples, and anti-patterns. Load the reference when you're actually applying the technique — don't read them all upfront.

Technique When to use Reference
1. Branded IDs Two or more entity ID types that are structurally string but semantically distinct. Prevents "I passed a user ID where a task ID was expected" at compile time. branded-ids.md
2. ?: never mutual exclusion A discriminated union where each variant has a distinct set of required fields, and you want the variants to be mutually exclusive without adding a literal tag field. Used by Vercel AI SDK, tRPC. discriminated-unions.md
3. NoInfer<T> A generic function where one parameter position should drive inference and another should only consume the inferred type. Prevents a late argument from widening the inferred type. generic-positions.md
4. Negative type tests Architectural invariants you want the compiler to enforce forever, not just in the current commit. @ts-expect-error locks the invariant into the test suite. negative-type-tests.md
5. Sentinel constants A magic string that must validate against a schema (e.g., a "not yet known" journal ID that still matches the format regex). Replaces '__magic__' with a typed constant. branded-ids.md
6. Cast cleanup Auditing existing code for as expressions. The goal is reducing the count while distinguishing trust-boundary casts (legitimate) from production casts that hide real type holes. cast-cleanup.md

Entry points

Invocation What happens
/type-safety audit Read the target file/package, enumerate all as expressions and ID type usage, produce a prioritized list of fixes. Doesn't modify code — just reports.
/type-safety design You're about to design new types and want the playbook loaded before you start. Read the main SKILL.md + the most relevant reference(s).
/type-safety <technique> Jump to a specific technique's reference. E.g., /type-safety branded-ids, /type-safety mutual-exclusion.
/type-safety <file-path> Audit a specific file. Runs the audit flow scoped to that file.
/type-safety (no args) Load the playbook overview (this file). Choose a technique based on context.

How the techniques compose

These six patterns are not independent — they reinforce each other. A coherent type-safe codebase typically applies several together:

  • Branded IDs + factory helpers give you compile-time ID distinction and runtime validation at boundaries. The brand alone doesn't validate format; the factory (asTaskId(raw)) does. Together they make it impossible to get a branded TaskId that didn't pass through the parser.
  • Branded IDs + sentinel constants let you use "not yet known" values without breaking the brand. UNRESOLVED_JOURNAL_ID = asJournalId('jrnl_000000000000000000000') is a valid JournalId that satisfies the regex — callers can compare to the sentinel without special cases.
  • Mutual exclusion + type guards make discriminated unions self-documenting. config.model !== undefined is a true type guard; 'model' in config is a weaker check that doesn't narrow never-typed fields.
  • Negative type tests + mutual exclusion lock architectural invariants into the test suite. If a future PR removes the ?: never fields, the @ts-expect-error test stops expecting an error and fails — the compiler catches the regression.
  • Cast cleanup + branded IDs is the classic migration pattern. Start by adding brands at the canonical definition, then delete casts one at a time. Each cast removal either succeeds (the cast was unnecessary) or surfaces a real type hole that needs fixing.

Composition with other skills

Skill Relationship
/tdd Negative type tests are a form of TDD — they're tests that the compiler runs. /tdd covers runtime behavior testing; this skill covers type-level testing. Cross-load when writing tests for type-sensitive APIs.
/implement When a story involves type design (new domain entities, new discriminated unions, new SDK surface), load this skill before the iteration to apply the playbook up front. Retrofitting type safety is 10x the cost of designing it in.
/decompose During story decomposition, flag stories that introduce new entity IDs or API surface. Those stories should reference this skill in their implementation notes.
/debug If a bug was caused by mistaking one ID type for another, or by violating a discriminated union's intent, the fix is a type-safety upgrade, not a runtime check. Load this skill during debug for structural fixes.
/review-local / /review-cloud Reviewers should flag unnecessary casts, untyped IDs on public APIs, and discriminated unions without mutual exclusion. This skill defines what "unnecessary" means.

Audit flow (for /type-safety audit)

When invoked with audit, run this flow:

  1. Scope — identify the target (file, package, or whole repo). Default: current package.
  2. Enumerate casts — grep for as in production code (exclude tests, exclude .d.ts). Group by file.
  3. Enumerate ID types — find all string fields on public interfaces that represent entity IDs. Look for suffixes like Id, UUID, Key, Hash.
  4. Enumerate discriminated unions — grep for | in type declarations. Check each union for mutual exclusion.
  5. Classify — for each finding, determine:
    • Cast: trust-boundary (legitimate) vs production hole (fix target)
    • ID: already branded / structurally string but should be branded / internal-only (no change needed)
    • Union: has mutual exclusion / relies on structural discrimination / needs tag field
  6. Prioritize — production holes before stylistic improvements. Public API before internal types. Count-reducing changes (branded IDs eliminate N casts at once) before one-offs.
  7. Report — produce a markdown table of findings with file:line, current state, recommended fix, and estimated impact (casts eliminated, invariants captured).

Do NOT modify code during audit. Report only.


Anti-patterns

  • Branding every string. If two ID types never appear together in the same function signature, there's no confusion to prevent. Don't brand email: string unless you have a real bug where emails and usernames get swapped.
  • Using ?: never on literal-discriminated unions. If your union already has type: 'a' vs type: 'b', adding ?: never fields is redundant — the literal already discriminates. Use one or the other.
  • NoInfer on return types. NoInfer is for consumption positions (parameters). Using it on return types doesn't do anything useful and confuses readers.
  • Negative type tests without anchor comments. A bare @ts-expect-error passes when any error occurs on the next line. Anchor to the specific error: // @ts-expect-error — assigning TaskId to ActorId. If the error changes, the test should fail.
  • as unknown as T. This is never acceptable in production code. It's a confession that you're lying to the compiler. If you truly need this, you have a real design problem — fix the design, don't double-cast.
  • Replacing all magic strings with sentinels. Only magic strings that need to validate against a schema warrant a sentinel constant. A plain discriminant string like 'pending' is not a magic string.
  • Treating this playbook as universally applicable. These patterns exist because TypeScript's type system is structural. They're expensive to write and read. Apply them where the cost is justified by the invariant being captured. In a small app with three tables, branded IDs are overkill.

References

File Covers
branded-ids.md Zod .brand<T>(), factory helpers, Drizzle .$type<T>() column branding, sentinel constants
discriminated-unions.md ?: never mutual exclusion, type guards via !== undefined
generic-positions.md NoInfer<T> on consumption positions
negative-type-tests.md @ts-expect-error placement and anchoring
cast-cleanup.md Auditing as expressions, trust boundaries vs type holes
Related skills

More from inkeep/team-skills

Installs
7
GitHub Stars
10
First Seen
Apr 8, 2026