type-safety
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 brandedTaskIdthat 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 validJournalIdthat satisfies the regex — callers can compare to the sentinel without special cases. - Mutual exclusion + type guards make discriminated unions self-documenting.
config.model !== undefinedis a true type guard;'model' in configis a weaker check that doesn't narrownever-typed fields. - Negative type tests + mutual exclusion lock architectural invariants into the test suite. If a future PR removes the
?: neverfields, the@ts-expect-errortest 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:
- Scope — identify the target (file, package, or whole repo). Default: current package.
- Enumerate casts — grep for
asin production code (exclude tests, exclude.d.ts). Group by file. - Enumerate ID types — find all
stringfields on public interfaces that represent entity IDs. Look for suffixes likeId,UUID,Key,Hash. - Enumerate discriminated unions — grep for
|in type declarations. Check each union for mutual exclusion. - 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
- Prioritize — production holes before stylistic improvements. Public API before internal types. Count-reducing changes (branded IDs eliminate N casts at once) before one-offs.
- 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: stringunless you have a real bug where emails and usernames get swapped. - Using
?: neveron literal-discriminated unions. If your union already hastype: 'a'vstype: 'b', adding?: neverfields is redundant — the literal already discriminates. Use one or the other. NoInferon return types.NoInferis 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-errorpasses 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 |
More from inkeep/team-skills
qa
Manual QA testing — verify features end-to-end as a user would, by all means necessary. Exhausts every local tool: browser (Playwright), Docker, ad-hoc scripts, REPL, dev servers. Mock-aware — mocked test coverage does not count. Proves real userOutcome at highest achievable fidelity. Blocked scenarios flow to /pr as pending human verification. Standalone or composable with /ship. Triggers: qa, qa test, manual test, test the feature, verify it works, exploratory testing, smoke test, end-to-end verification.
61cold-email
Generate cold emails for B2B personas. Use when asked to write cold outreach, sales emails, or prospect messaging. Supports 19 persona archetypes (Founder-CEO, CTO, VP Engineering, CIO, CPO, Product Directors, VP CX, Head of Support, Support Ops, DevRel, Head of Docs, Technical Writer, Head of Community, VP Growth, Head of AI, etc.). Can generate first-touch and follow-up emails. When a LinkedIn profile URL is provided, uses Crustdata MCP to enrich prospect data (name, title, company, career history, recent posts) for deep personalization.
54spec
Drive an evidence-driven, iterative product+engineering spec process that produces a full PRD + technical spec (often as SPEC.md). Use when scoping a feature or product surface area end-to-end; defining requirements; researching external/internal prior art; mapping current system behavior; comparing design options; making 1-way-door decisions; negotiating scope; and maintaining a live Decision Log + Open Questions backlog. Triggers: spec, PRD, proposal, technical spec, RFC, scope this, design doc, end-to-end requirements, scope plan, tradeoffs, open questions.
54ship
Orchestrate any code change from requirements to review-ready branch — scope-calibrated from small fixes to full features. Composes /spec, /implement, and /research with depth that scales to the task: lightweight spec and direct implementation for bug fixes and config changes, full rigor for features. Produces tested, locally reviewed, documented code on a feature branch. The developer pushes the branch and creates the PR. Use for ALL implementation work regardless of perceived scope — the workflow adapts depth, never skips phases. Triggers: ship, ship it, feature development, implement end to end, spec to PR, implement this, fix this, let's implement, let's go with that, build this, make the change, full stack implementation, autonomous development.
52docs
Write or update documentation for engineering changes — both product-facing (user docs, API reference, guides) and internal (architecture docs, runbooks, inline code docs). Builds a world model of what changed and traces transitive documentation consequences across all affected surfaces. Discovers and uses repo-specific documentation skills, style guides, and conventions. Standalone or composable with /ship. Triggers: docs, documentation, write docs, update docs, document the changes, product docs, internal docs, changelog, migration guide.
52implement
Convert SPEC.md to spec.json, craft the implementation prompt, and execute the iteration loop via subprocess. Use when converting specs to spec.json, preparing implementation artifacts, running the iteration loop, or implementing features autonomously. Triggers: implement, spec.json, convert spec, implementation prompt, execute implementation, run implementation.
52