lucasfcosta/backpressured/type-design-review

Claude Code and agent skill by lucasfcosta/backpressured

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

Use when a backpressured code-review subagent is judging whether a diff uses the type system to make illegal states unrepresentable — i.e. the diff touches data models, function signatures, or domain types. TypeScript-centric, with mappings to Rust, Swift, Kotlin, and Haskell/OCaml.

Install

npx vskill@latest install lucasfcosta/backpressured/type-design-review

SKILL.md

Type-Design Review

Overview

Push every invariant you can into the types, so the compiler — not a runtime check, not a test, not a human reviewer — is the thing that says "no." This is the type-system instance of backpressure: a contradictory state that cannot be constructed needs no defensive check, no test, and no "this should never happen" comment, because it genuinely never can.

You are reviewing a diff for this property. Your job is not "do the types compile" — it is "could a wrong state have been represented at all, and if so, why wasn't it designed out?"

Three ideas, one throughline:

  • Make impossible states impossible (Richard Feldman, elm-conf 2016): if the app forbids a combination of data, design the data model so that combination cannot be expressed.
  • Parse, don't validate (Alexis King, 2019): don't check data and throw the knowledge away — parse it into a type whose existence is the proof of the invariant, then carry that type inward.
  • Make illegal states unrepresentable (Yaron Minsky, Effective ML, c. 2010): making the wrong thing hard to express beats checking for the wrong thing at runtime.

When to Use

  • A backpressured code-review subagent is reviewing a diff (per-iteration or whole-changeset) and the diff introduces or changes data models, function signatures, or domain types.
  • New state is modelled with booleans / optional fields, new switch/match over a union, new as/! assertions, or new functions taking raw string/number.

Not for: pure formatting, dependency bumps, or changes with no type-level surface. And not as a general reviewer — correctness/logic bugs, simplicity/reuse, and test quality belong to the general code-review reviewer. This skill is only the type-design lens: could a wrong state have been represented at all?

The one question — apply it to every type in the diff

For each data model, signature, or domain type the diff adds or changes: enumerate the states the type permits, then ask which of those the domain forbids. Each permitted-but-forbidden state is a candidate finding, and the fix is to restructure so it can no longer be expressed. That single question is the review — the smells below are common instances of it, not a closed checklist. A diff can make an illegal state representable in a shape no row names (end before start, min > max, a list that should be a set, three different nulls meaning three different things); that still counts, and you are expected to catch it from the principle. Run the question first; reach for the table for vocabulary and ready-made fixes, not as the boundary of what to look for.

The smells — common instances (not a checklist)

Treat each row as a finding only when it actually makes a domain-forbidden state representable here — not on sight. The left column is a pattern to notice; it becomes a finding only once you can name the real illegal state it permits in this code (a matching pattern that forbids nothing real is not a finding — see Proportionality).

SmellWhy it's a bug waiting to happenPush toward
Boolean flags that can't all be true at once (isLoading + isError + isSuccess)2^n combinations exist; most are nonsense the types permitone discriminated union with a status discriminant
Optional fields that are really mutually exclusive (error? and data?)"loaded and erroring" is representable but meaninglessa tagged union, one variant per real state
as / as any / <T>x casting into or out of a unionan assertion the compiler can't verify — reintroduces the runtime error types exist to preventnarrow with a user-defined type guard, or parse
Non-null assertion ! to silence "possibly undefined"crashes at runtime if the value is nullnarrow with a check, or restructure so it can't be null
switch/match/when with no exhaustiveness checkadding a variant later silently falls through instead of failing the builddefault: return assertNever(x) (see below)
Primitive obsession / stringly-typed (string for ids, status, email, money, units)UserId and OrderId are interchangeable; typos compilebranded/newtype/opaque types, literal unions
Validation returning boolean or voidthe knowledge is discarded; every caller must re-trusta parser returning a proof-carrying type
Two fields that must stay in sync (list + selectedIndex, cached count + array)desync is representable; one can be updated without the othera single structure that makes desync impossible; derive don't store
enum where a string-literal union would doruntime footprint + surprising semantics (numeric enums accept any number)`"a" \"b" union, often as const`-derived
Mutable shared/value-object statealiasing bugs, "who mutated this?"readonly, ReadonlyArray<T>, Readonly<T>

Proportionality — generalize the principle, don't gold-plate

Applying the principle broadly is not licence to maximize type ceremony. Flag a representable illegal state when all three hold: it's realistically reachable (not merely theoretical), it would cause a real bug or force defensive checks / "this should never happen" comments downstream, and the restructuring is reasonably cheap and lives in domain/core code. Otherwise, leave it.

Do not:

  • brand or wrap a value with no invariant to prove — free text a user types (a note, a description) is just string;
  • force a union onto booleans/optionals that genuinely can coexist — a booking that is both paid and cancelled (refund pending) is a real state, and merging it would lose information, not add safety;
  • restructure a throwaway local or one-shot internal shape with no downstream consumers.

When the domain genuinely permits every combination, the permissive type is the correct type — match the domain, not your appetite for tighter types. The test is always "name the forbidden state this prevents"; if you can't name one, it isn't a finding.

Core pattern: impossible states, before/after

ts
// BEFORE — a bag of optionals. isLoading && error && data is representable and meaningless.
interface State {
isLoading: boolean;
error?: Error;
data?: User;
}
// AFTER — a discriminated union. Only the four real states exist; narrowing on
// `status` gives you exactly the fields valid in that state, nothing else.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };

Core pattern: exhaustiveness with never

ts
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${String(x)}`);
}
function render(s: State): string {
switch (s.status) {
case "idle": return "";
case "loading": return "Loading…";
case "success": return s.data.name;
case "error": return s.error.message;
default: return assertNever(s); // add a variant → this line stops compiling
}
}

The default: assertNever(s) line is the point: a forgotten case becomes a build failure, not a silent runtime fallthrough. Flag every exhaustive-looking switch that lacks it.

Core pattern: parse, don't validate

ts
// BEFORE — validate returns a boolean; the result is lost, every caller must re-trust.
function isEmail(s: string): boolean { /* ... */ }
function sendInvite(email: string) { /* hope the caller checked */ }
// AFTER — parse returns a branded type; sendInvite cannot be called with an unparsed string.
type Email = string & { readonly __brand: "Email" };
function parseEmail(s: string): Email | undefined { /* check, then brand */ }
function sendInvite(email: Email) { /* the type IS the proof it was parsed */ }

Parse at the boundary (where untyped input enters), then pass the precise type inward. King's rule of thumb: treat functions that return void (Haskell's ()) with deep suspicion — their effects are all you get; nothing is preserved in the types.

Recommend the project's existing parser before a hand-rolled one. Before suggesting a x is T guard, check what the repo already has — a schema/validation library in package.json or imported nearby (Zod, Valibot, ArkType, io-ts, runtypes, Yup, @effect/schema, …). If one exists, prefer pointing the fix at it (Schema.safeParse(input)): usually less code than a bespoke guard, a runtime error message for free, and consistent with the rest of the codebase. A hand-written guard is still perfectly fine for a trivial or local check — the rule isn't "always use the library," it's don't reinvent a parser the project already has, and don't tell the author to add a new dependency just to satisfy this review (suggesting they adopt one is fair at a real, repeated boundary; for a one-off, a hand guard or a flagged follow-up is the lighter call). Match the tool the project already uses, not your favorite.

Branded / nominal types

TypeScript is structural, so UserId and OrderId are both just string and freely interchangeable. A phantom brand makes them distinct:

ts
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
declare function getUser(id: UserId): User;
declare const oid: OrderId;
getUser(oid); // ERROR — OrderId is not assignable to UserId

Brands should only be minted by a parser/constructor — that is what ties them to "parse, don't validate."

When an escape hatch is acceptable

as and ! are not banned — they are unchecked, so each one must be justified in the diff:

  • ✅ as const (narrows + freezes a literal — not an unsafe assertion).
  • ✅ x satisfies T instead of const x: T = … when you want the check and the narrow inferred type.
  • ✅ An as/! immediately after a real runtime check you can see, against a truly external invariant.
  • ❌ JSON.parse(body) as User, resp as SuccessResponse, maybeUser!.name — assertions of facts nothing verified. Push toward a type guard (x is T) backed by a real check, or a parser built with the project's existing schema library (see "Recommend the project's existing parser" above).

If the diff adds an as/! with no nearby justifying check, flag it and name the narrowing or parse that would replace it.

Same principle, other languages

The TypeScript mechanics above are one dialect of a language-agnostic idea: a closed sum type the compiler checks for exhaustiveness, no implicit null, and a newtype to defeat primitive interchangeability.

LanguageSum type / illegal-state mechanismExhaustiveness & null
Rustenum with data per variant; newtype struct UserId(u64)match exhaustive by default; Option<T>/Result<T,E>, no null
Haskell / OCamlADTs + smart constructors (the origin lineage)incomplete-pattern warnings; Maybe/option, no null
Swiftenum with associated values; struct for productsswitch must be exhaustive; Optional<T> (T?)
Kotlinsealed class/interface hierarchieswhen expression must be exhaustive; nullable T? tracked

When reviewing non-TypeScript code, apply the same questions: is this a closed set of states the compiler can check? Is absence modelled explicitly rather than with null? Are domain ids distinct types or bare primitives?

How to report findings

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
63
Repository forks
2
7-day trend score
0

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/lucasfcosta/backpressured/type-design-review/badge)](https://verified-skill.com/skills/lucasfcosta/backpressured/type-design-review)

Something wrong with this skill or its page? Tell us.