When designing a TypeScript library's public types, when should a type be derived from an implementation value using `infer`-based extraction, and when should it be declared explicitly?
answer
- who owns the truth here
- derived types follow the implementation
- the .d.ts is the published contract
- errors name a type or expand a shape
- satisfies checks without dictating
basics
~20 sDerive when the implementation value is the real source of truth and drift is the risk. Declare explicitly when the type is a contract others depend on, so an implementation edit cannot silently change what consumers see.
solid answer
~40 sThe question is really "who owns the truth". Deriving with `infer` gives you a single source of truth: a route table or config object is written once, and the type follows automatically, so the two can never disagree. The cost is that every implementation edit becomes a potential public-contract change — add a field to that object and the emitted `.d.ts` changes for every consumer, with nobody reviewing a type. Errors also get worse: consumers see an inferred structural blob rather than a named type, and the failure appears far from its cause. My rule is to derive internal types freely, but to declare the types on the public boundary and then check the implementation against them with `satisfies`, which keeps the value honest without letting the value dictate the contract.
code
typescript · 12 linestype Elem<T> = T extends readonly (infer U)[] ? U : never;
// Derived: the value owns the truth
const levels = ["debug", "info", "error"] as const;
type DerivedLevel = Elem<typeof levels>; // "debug" | "info" | "error"
// Declared, with the value checked against it
type Level = "debug" | "info" | "error";
const checked = ["debug", "info", "error"] as const satisfies readonly Level[];
const current: Level = checked[1];
console.log(current);go deeper
Understand that a type can either be written by hand or derived from a value, and that either way it disappears at compile time and costs nothing when the program runs.
Be able to derive a union from a const array with an extraction helper, and explain why deriving prevents the type from drifting away from the value it describes.
Argue the review and error-message consequences: a derived public type moves without a diff a human can approve, and it turns clear failures into expanded structural blobs far from their cause.
Set the boundary policy for the codebase — derive inside modules, declare on the exported surface, verify with satisfies — and defend it against both "always infer" and "always annotate" absolutism.
## Two directions of truth There are only two arrangements. Either the value is authoritative and the type is derived from it, or the type is authoritative and the value is checked against it. `infer`-based extraction is the tool for the first direction: given a concrete object, function or table, you match its type and pull out the pieces you want. ```ts const routes = { home: "/", user: "/users/:id" } as const; type RouteMap = typeof routes; type RouteName = keyof RouteMap; type Elem<T> = T extends readonly (infer U)[] ? U : never; const levels = ["debug", "info", "error"] as const; type Level = Elem<typeof levels>; // "debug" | "info" | "error" ``` That is genuinely valuable. The list exists at runtime anyway, and now the type cannot drift from it. Nobody has to remember to update a union when they add a level. ## What derivation costs at a boundary **The public contract becomes implicit.** Adding an entry to `levels` silently widens a published union. No reviewer sees a type change in the diff, because there is no type in the diff. For an internal module that is fine; for a package other teams compile against, it means the contract moves without anyone approving the move. **Error messages degrade.** A consumer who violates a declared, named type is told which named type they failed. A consumer who violates a derived type is shown the expansion of a conditional over a structural shape, often several levels deep. The distance between the error and the edit that caused it grows with every layer of extraction. **Refactors leak.** Rename a field in an internal object and, if a public type was derived from it, the rename crosses the package boundary. The implementation was supposed to be free to change; derivation quietly welded it to the API. **Emit becomes fragile.** When a declaration file has to name a derived type, the compiler must be able to write it down. Chains of conditionals over local values produce large, unstable declaration output, and "the `.d.ts` changed and I don't know why" is a genuinely expensive class of bug in a published package. ## What declaration costs Declaring the type by hand reintroduces the possibility of drift: the type says one thing, the value says another, and nothing forces them together. That is the exact problem derivation solved, so declaring alone is not the answer either. ## The arrangement that gets both Declare the contract, then check the implementation against it rather than deriving from it: ```ts type Level = "debug" | "info" | "error"; const levels = ["debug", "info", "error"] as const satisfies readonly Level[]; ``` Now `Level` is a named, reviewable, stable public type; the array is verified against it; and — because `satisfies` checks without widening — the literal type of `levels` is still available for anything internal that needs it. Adding a level is now a two-line change where one of the lines is the contract, which is precisely the thing you wanted a human to look at. ## Where to draw the line in practice - **Derive freely inside a module.** Internal helpers, test fixtures, and anything not exported can follow the value. The blast radius is the file. - **Declare on the exported surface.** Anything in the package's public API gets a written, named type. Consumers should be able to read the contract without evaluating a conditional. - **Never make a public type depend on a private implementation detail.** If the derivation reaches into something you intend to change freely, that intent is now a fiction. - **Prefer one shallow extraction over a chain.** Two or three layers of extraction is where error messages stop being readable and where consumers stop being able to explain what a type is. ## The judgment being tested The wrong answers are both absolute. "Always derive, single source of truth" ignores that a published type *is* a contract and contracts should change deliberately. "Always declare, be explicit" reintroduces drift and duplicates information the program already contains. What an interviewer wants is the boundary argument: derivation is an implementation technique, declaration is an interface decision, and the boundary between them is the package's public surface.
- Why does deriving a public type from an implementation value make code review weaker?Because the contract change never appears as a type change in the diff. Someone edits an object literal, the emitted declarations shift, and consumers get a different API with no reviewer having looked at an interface. Declaring the type puts the contract back into the diff, where a human can approve or reject the change.
- How does `satisfies` let you keep a single source of truth without letting the value define the contract?`satisfies` checks a value against a type without widening the value's inferred type. The declared type stays authoritative and reviewable, the value is verified against it so it cannot drift, and the precise literal type of the value remains available for internal use. You get the guarantee of derivation with the stability of declaration.
- What would make you accept derived types on a public surface anyway?An internal package with one consuming team, a genuinely generated surface where the value is the specification, or a prototype where API stability is not yet a goal. The trade is deliberate: you accept opaque errors and silent contract movement in exchange for never having to synchronise two things.
saying these in an interview costs you the question
- Claims derived types cost something at runtime
- Treats inference as always preferable to declaring
- Ignores that consumers compile against the emitted .d.ts
- Thinks satisfies widens the value's inferred type
- Derives public types from deliberately private internals