Type Design Review logo

Type Design Review

Community
lucasfcosta
type-design-review

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.

Overview

Publisherlucasfcosta
Repositorybackpressured
Skill nametype-design-review
Stars
63
Forks
2
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by lucasfcosta on GitHub. Read the source before you install it.

Installation

Install the Type Design Review AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/lucasfcosta/backpressured/tree/main/skills/type-design-review
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/lucasfcosta/backpressured.git /tmp/backpressured
mkdir -p .claude/skills
cp -r /tmp/backpressured/skills/type-design-review .claude/skills/type-design-review
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Type Design Review in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Type Design Review on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Type Design Review is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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

For each finding give: the smell (from the table), the state that is wrongly representable ("error and data can both be set"), and the concrete restructuring (the union/brand/parser to use). A finding without a representable-wrong-state is not a type-design finding — hand it to the general code-review reviewer instead. Push back is expected: if the author justifies an escape hatch with a real boundary check, that is a resolved finding, not a violation.

Common rationalizations

RationalizationReality
"The booleans are never set inconsistently in practice""In practice" is exactly the invariant the types should enforce. If it can't happen, make it unrepresentable.
"as User is fine, the API always returns that shape"The compiler didn't verify it and the API can change. Parse at the boundary; carry the type in.
"Adding assertNever is boilerplate"It is the difference between a forgotten case being a compile error vs. a production bug.
"It's just an id, a string is simpler"A string lets OrderId flow into getUser(userId) silently. A brand costs one type and removes the whole class.
"Validation already happens at the controller"If the type doesn't say so, the next caller doesn't know. Encode the proof, don't rely on convention.

Sources

  • Richard Feldman, Making Impossible States Impossible, elm-conf 2016 — the SelectList example (a list whose selection is part of the structure, so you cannot select an item not in the list).
  • Alexis King, Parse, don't validate (2019-11-05): parse into a proof-carrying type at the boundary. Two of her closing maxims (distinct bullet points in the essay): "Use a data structure that makes illegal states unrepresentable." and "Push the burden of proof upward as far as possible, but no further."
  • Yaron Minsky, Effective ML (c. 2010, Harvard CS51 / Jane Street), popularized via Real World OCaml: "make illegal states unrepresentable."

Frequently asked questions

What does the Type Design Review AI skill do?

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.

Why use Type Design Review on TypingMind?

Because you install it once and use it with any model. Type Design Review is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Type Design Review in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/lucasfcosta/backpressured/tree/main/skills/type-design-review. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Type Design Review?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Type Design Review?

As many as you like. As long as a model supports skills, you can use Type Design Review with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Type Design Review AI skill free?

Yes. It is published on GitHub by lucasfcosta under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇