What this post is about
In my last post I talked about filtering a non-deterministic AI producer through deterministic checkpoints. I used the thermometer, scale, and timer analogy.
This post zooms in on one of those checkpoints: the type check. But there's a trap here.
Complicated types don't mean safe code.
Ask an AI to "make this type-safe" and you get something that looks sophisticated. Nested conditional types, mapped types churning away, infer sprinkled in. Then you open it up and find things like this.
- The generic's inference authority is scattered across several parameters, so a union quietly widens to
string - A conditional type distributes over a union unintentionally, or mishandles
never - A mapped type drops
readonlyor optional markers - Helper-level tests (
Equal<A, B>) pass, but the actual public call still accepts a wrong value - Type errors get hidden behind
any, assertions,@ts-ignore, orskipLibCheck - A discriminated union gets built, and then the report claims async ordering is solved too
All of it compiles. So the signal "type check passed" tells you nothing on its own.
This post is my notes on how the frontend-oracle-design workflow I use handles that problem, and how you can apply the same approach elsewhere.
First: a checkpoint is a filter, not a stamp of approval
Let me define one term up front. Soundness is the property that "if it passed the type check, that class of runtime error can never happen."
TypeScript is deliberately unsound. That's a design decision, not a bug.
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // passes
animals.push(new Cat()); // passes
dogs[1].bark(); // 💥
Treating arrays as covariant, making an exception for bivariance in method shorthand syntax, applying excess property checks only to object literals — these are all holes accepted for the sake of practicality. Make it strict and most real code stops compiling.
So here's how I frame it.
Compiling isn't a proof of safety. It's a cheap, deterministic filter.
Cheap means it's far faster than a human review, and deterministic means the same code, the same compiler, and the same config always produce the same verdict. Those two properties are exactly what you need to filter AI output.
Restating the goal: you can't make AI generation itself deterministic. What you can do is pass or reject the many candidates an AI produces by the same standard. Not deterministic generation — deterministic acceptance.
Philosophy 1 — Pin down who owns what
The moment you assume types are responsible for everything, false reporting begins. So start by splitting ownership.
| Subject | Owner |
|---|---|
| Values, props, state combinations, input/output relations | Types |
| External input like APIs, storage, URLs, messages | Runtime parsers starting from unknown |
| Observable product behavior | Tests |
| Out-of-order responses, duplicate submits, retry, arrival after unmount | Abort signals, duplicate guards, idempotency keys, server validation |
| Reproducible generation from the same prompt | The model and the provider — this workflow doesn't guarantee it |
The fourth row is the most important one here.
The time axis is not provable by types.
Writing "ordering problem solved" because you built a union is simply false. Request A going out first and response B arriving first is a region the type system cannot see. Runtime devices like abort signals or request-ID comparison are what block that.
One line summary:
type-validis not a synonym forbehavior-correct.
Reporting a type check pass as if it meant the behavior is correct is a defect in itself. And this is a report AI makes really often. It fixes the types and then says "handled safely."
Anything types couldn't catch and runtime had to block must go in the decision record. If you don't record it, the next person thinks "types cover this here" and deletes the defense.
Philosophy 2 — Use a ladder to drop over-engineering
The harder you push an AI, the more elaborate the types it produces. The problem is that elaborateness isn't the goal.
So I use a ladder. If an earlier rung already closes the actual misuse, I don't use a later rung.
1. Make wrong combinations impossible by omitting an API or splitting the API
2. Derive from schema, config, or as const values via typeof, keyof, indexed access, satisfies
3. Use the built-in utilities (Pick, Omit, Extract, Exclude, Parameters, ReturnType, Awaited, NoInfer, Record)
4. Reuse types from libraries already installed
5. Use the minimal generic that infers automatically at a representative call site
6. If the relation still isn't closed, isolate mapped, conditional, template-literal, recursive types in types/internal
Rung 1 is the strongest and the most often forgotten. The safest API is one that has no way to be used wrong.
Solving the same problem at rung 6, rung 3, and rung 1
Saying it in words doesn't land, so let's look at one situation that comes up all the time.
There's a calendar component. When
modeissingleit handles a single date, when it'srangea period, and when it'smultiplean array of dates. The types ofvalueandonChangehave to differ by mode.
What the AI brings back (rung 6 — conditional type)
type CalendarMode = "single" | "range" | "multiple";
type CalendarValue<M extends CalendarMode> = M extends "single"
? Date | null
: M extends "range"
? DateRange | null
: M extends "multiple"
? readonly Date[]
: never;
type CalendarProps<M extends CalendarMode> = {
mode: M;
value: CalendarValue<M>;
onChange: (value: CalendarValue<M>) => void;
};
It works. But it comes with costs. M is a naked type parameter, so a union coming in gets distributed and produces results nobody intended, and nobody knows when that final : never branch fires. And when there's a type error, this is the message the consumer sees.
Type 'Date' is not assignable to type 'CalendarValue<M>'.
Type 'Date' is not assignable to type 'M extends "single" ? Date | null : ...'
One rung down (rung 3 — lookup map + indexed access)
All the conditional type is really doing is "pick one type by mode name." So just make a map.
type CalendarValueByMode = {
single: Date | null;
range: DateRange | null;
multiple: readonly Date[];
};
type CalendarMode = keyof CalendarValueByMode; // 'single' | 'range' | 'multiple'
type CalendarProps<M extends CalendarMode> = {
mode: M;
value: CalendarValueByMode[M];
onChange: (value: CalendarValueByMode[M]) => void;
};
The same relation, expressed with just two things: keyof and indexed access. There's no distribution trap, the mode list is derived from the map so the two can't drift apart, and adding a new mode means adding one line to the map. The error message now reads Type 'Date' is not assignable to type 'DateRange | null'.
The misuse is actually closed here. So rung 6 doesn't get used.
<Calendar mode="range" value={new Date()} onChange={...} />
// ~~~~~ Type 'Date' is not assignable to 'DateRange | null'
Further down (rung 1 — omitting or splitting the API)
But ask one more question. Does the product use all three modes?
If nobody uses multiple, that isn't something "to block with types" — it's something that shouldn't exist in the first place. And if single and range differ all the way down to keyboard interaction and internal state management, bundling them into one component was a stretch to begin with.
// the mode prop itself disappears
export function CalendarSingle(props: {
value: Date | null;
onChange: (value: Date | null) => void;
}) { /* ... */ }
export function CalendarRange(props: {
value: DateRange | null;
onChange: (value: DateRange | null) => void;
}) { /* ... */ }
The generic is gone. There's no syntax to even express the wrong combination. Code that passes a DateRange to CalendarSingle can't be written in the first place.
So which one do you pick
| Situation | Answer |
|---|---|
| The mode mechanically determines only the value type | Rung 3 — lookup map |
| Behavior, keyboard interaction, and state lifetime differ per mode | Rung 1 — split the components |
| The product doesn't use that mode | Rung 1 — don't implement it (omit the API) |
The judgment question is this. "If you deleted the mode, would what's left be the same component?" If it is, only the value type differed, so rung 3. If it isn't, they were different components all along.
One thing to watch out for. There are several ladders, and they're independent of each other.
A. Ownership & boundary reuse an existing owner → derive → make it impossible by omitting the API → parse external values from unknown
B. State space consume the framework union → split by capability → union + never → discriminated union → state machine
C. API relations typeof, as const, satisfies → keyof, indexed access → built-in utilities → relational generics → ...
There is no global ordering like "keyof always comes before discriminated unions." Line up problems from different axes and you'll get nonsense verdicts. Check from the first rung within each ladder only.
And the purpose of the ladder is interesting. It isn't to make generated output identical, it's to consistently drop unnecessarily complex later-rung mechanisms. So when the AI shows up with a six-level conditional type, you can say "rung 3 closes this" with the same reasoning every time.
Philosophy 3 — Converge on a single judgment question
There's exactly one question that runs through this whole workflow.
Of the wrong code the AI could have generated, what no longer compiles?
The nice thing about this question is that the answer is either concrete or nonexistent.
"Type safety is improved" is not an answer. "If you delete the column, the three call sites that sorted by that id fail to compile" is an answer. And the latter becomes a test case as is.
Type complexity you can't answer for concretely doesn't get added. It's surprising how much this single rule filters out.
Six places to look before designing
When looking for what to close, check these six. And write down the wrong usages that must not compile, first.
| Place | Symptom | Candidate |
|---|---|---|
| Values | Wide string, number, Date | Branded types, meaning types |
| Combinations | Several booleans, mutually exclusive optional props | Discriminated union, union + never |
| Relations | The mode determines the return type but the type doesn't say so | Generic lookup map, splitting components |
| Paths & keys | Route, query key, field path as free strings | Factory, keyof, derived union |
| Results | Success, failure, absence, keep, delete all packed into one undefined | Result, operation union |
| Extension | Keys consumers extend are open as string | Typed registry, module augmentation |
If it's a public API, that list becomes the @ts-expect-error cases in your .test-d.ts as it is. Notes from the design phase flow straight into verification code. How many you write is decided by the boundary axes in Application 1 below — you don't fix the count up front.
Application 1 — Demand an evidence packet
To adopt an advanced type, I leave six kinds of evidence with it. This is the device that separates "looks plausible" from "actually blocks."
| Evidence | Standard |
|---|---|
| positive | One representative product call compiles without explicit type arguments |
| negative | One per boundary axis this API closes, each one line of @ts-expect-error |
| edge | Only the relevant any, unknown, never, unions, readonly tuples, optionals |
| mutation | Weakening the contract actually turns the suite RED |
| runtime complement | URLs, storage, APIs, and the time axis are proven by parsers, guards, and runtime tests |
| soundness gap | Write down the remaining holes, like the last overload signature or method bivariance |
Why positive evidence is needed is the interesting part. If a normal call doesn't infer without type arguments, it isn't a good API no matter how many negative tests pass. Because nobody will use it. An API that's safe but unusable ends up the same as an unsafe one.
Mutation evidence matters even more. You deliberately widen the contract and check whether the tests go red. Change NoInfer<T> to T, widen a union to string, make a required field optional. If it doesn't go red, that test was protecting nothing.
Here's where the difference between @ts-expect-error and @ts-ignore becomes the whole point. If the error disappears, that line goes red as "unused" (TS2578). It's a structure where losing the protection is itself a failure.
Pick negative cases at the boundaries
Which misuses you block is decided by the boundary, not the count. Put a count up as the target — "at least three" — and people and tools alike fill the number: write three typos, call it three cases done. All three are the same boundary.
This is just boundary value analysis (BVA), long used in runtime tests, moved onto types as is. An "at least 2 characters" rule means you check 1, 2, and 3; types have boundaries you cross too. Only they aren't the size of a value, they're the extremes of the type lattice.
// boundary: literal / widened string
const ok: SortKey = "total";
// @ts-expect-error a value already widened to string can't come in
const bad: SortKey = "total" as string;
And there's one more important thing here. Some of BVA's four axes are ones types can't cover. The time/ordering boundary and the side-effect-count boundary stay uncovered no matter how carefully you build the union. Once you've filled in the type cases, you have to hand those two axes over to runtime tests explicitly. If you don't, "I wrote all the type tests" gets read as "everything is verified."
→ Type-level boundary value analysis — picking type test cases with BVA
→ Type-level testing — evidence the compiler leaves behind
Application 2 — Fix the environment assumptions once per repo
If "doesn't compile" is a function of tsconfig, then a verdict that doesn't know the environment isn't a verdict.
pnpm exec tsc --showConfig # effective values after following the extends chain
pnpm exec tsc --version # the compiler the lockfile actually resolved
The standard is the effective value, not what's written in the file. And a result that only passes in the Playground or on the latest version isn't evidence.
If strict or the version isn't satisfied, stop here. Don't quietly change tsconfig — that's a policy change rippling through the whole repo, so it's a human decision. If a recommended flag is off, propose turning it on, but if that's declined, write down the list of contracts that get weaker and proceed. It means "review and tests, not the compiler, have to catch this."
→ The environment contract behind type contracts
Application 3 — Keep a list of the false contracts that keep showing up
There are patterns that get caught repeatedly in review. Keeping them as a list speeds up judgment.
Record<K, V>is a totality contract. It means every K exists. If the result is sparse and only fills observed keys, usePartial<Record<K, V>>when the keys are a finite union, andMapwhen the domain is open like IDs.- A type predicate carries a checking obligation. Use
value is Tonly when the body actually checks the required invariant. A predicate wrapping anasis just lying to the compiler. - A wrapper's return contract follows its execution timing. Preserving
Parametersis fine, butReturnTypeonly when the value comes out of that same call. Debounce isvoid, a cache is| undefined. - Excess property checking is not a sanitizer. It only fires on object literal assignment, and even when it fires it doesn't strip the field at runtime.
- Put schemas at the boundary only. Finite values created inside the app are fine as
as const, and parsers belong at the read point where that value comes back from storage, a URL, or a response. Get it backwards and you only add cost while the actually risky spot stays empty.
→ Types that claim more than the runtime does
Application 4 — For state, block twice before you build a union
Talk about types long enough and you want to turn every piece of state into a discriminated union. But the real bugs happen before that.
- If it can be derived, don't store it.
itemCount = items.length. Duplicated state gets updated on one side only. - If a library already owns the union, consume it as is. A query library's
statusis already a discriminated contract, and it includes time-axis handling based on the latest call. Copy it intouseStateand you create a second truth that trails one render behind. - Only for the genuine client state that still remains, build a union plus intent functions.
And one more thing. State is data; actions are siblings.
// Forbidden — you get a stale closure and a fake retry at the same time
type DetailState = { status: "loading" } | { status: "failure"; retry: () => void };
// Allowed — state is data, actions are siblings
type DetailState = { status: "loading" } | { status: "failure"; reason: LoadFailure };
function useDetail(id: DetailId): { state: DetailState; retry: () => void };
A function stored in state is pinned to the closure of the render that created it. And filling states where it can't be used with a no-op like retry: () => undefined feeds false information to the UI.
Application 5 — Treat relaxing a contract file as a policy change
This might be the most important rule in practice.
Tell an AI "fix the type errors" and it finds the easiest path to making the errors go away. And that path is usually widening the contract.
- Delete an
@ts-expect-errorcase from a.test-d.ts - Make a required field optional
- Widen a union to
string - Add a
@ts-ignoreor a double assertion
All of them turn the light green. And all of them remove the protection.
Contract files are the root of trust for verification. Relaxing them is not an implementation decision, it's a policy change.
So if an implementation diff widened a contract, that isn't a "pass" — it's escalated as an item a human has to decide. If the requirements genuinely changed, cite the basis and widen it. If it was just about making the error go away, revert it.
It helps to keep a review checklist alongside.
- Storing derivable values as state / copying query state into a local machine / exposing a raw setter
- Storing actions in state, no-op actions for states where they can't be used
- Asserting boundary values without parsing,
anyleaking into the application layer - Later-rung types for a problem an earlier rung closes, homegrown advanced utilities in feature code
- Reporting
satisfiesandas constas runtime validation - Marking time-axis non-determinism "solved" with types alone
Conversely, decide what is not subject to judgment too. State naming taste, reducer versus individual handler syntax preference, pattern-matching library preference — those are opinions, not defects. Without that distinction, review turns into a taste fight.
Applying this elsewhere
Even if you don't use this exact approach, there are a few pieces worth carrying over.
One. Pick one judgment question. "What no longer compiles" is powerful because the answer is either concrete or nonexistent. You can make one of these for your domain too. Something like "if I delete this validation, which test goes red?"
Two. Make an ownership table. Writing down what types own, what parsers own, and what tests own reduces false reporting. The row spelling out "what the AI does not guarantee" matters most.
Three. Make a ladder. You're setting an order, not a tool list. That single rule — if an earlier rung closes it, don't use a later rung — keeps filtering out over-engineering.
Four. Make mutation checking a habit. Deliberately break the defense you built and check that the light turns red. If it doesn't, it was decoration.
Five. Split relaxations onto their own track. Changes that weaken a verification device should go through a different approval path than feature work.
Wrapping up
If I compress today's notes into one line:
A claim that types blocked something is true only when you can answer "what no longer compiles" concretely, and that answer usually turns false without any warning the moment one piece of syntax goes missing.
And what this workflow actually does isn't prove safety. It's make passing and rejecting repeatable by the same standard every time. The AI will keep producing different code, and the checkpoint just has to keep issuing the same verdict.
Related posts worth reading alongside this one.
- Lock the oracle before the implementation — the whole philosophy behind this workflow
- Non-deterministic AI code, filtered through deterministic checkpoints — the step-by-step procedure