You are designing a shared TypeScript utility library. How do you decide how tight a generic constraint should be, and what goes wrong when a constraint is tighter than the function body actually needs?
answer
- constrain to what you read
- the constraint is published API
- loosening is safe, tightening breaks callers
- as const versus mutable array parameters
- rejected callers assert, and assertions lie
basics
~20 sConstrain to the members the body actually reads, and no more. An over-tight constraint rejects callers whose values would work perfectly at runtime, pushing them into as assertions that relocate unsoundness into their code — and loosening it later changes your published contract.
solid answer
~60 sMy rule is *constrain to what you read*: write the body first, then require exactly the members it touches. A constraint is not documentation of the intended use case, it is a filter on who may call you. Over-constraining has a specific failure signature — a call-site assignability error on a value that would work fine at runtime. The classic is `T extends unknown[]`, which rejects anything built with `as const` because a `readonly` tuple is not assignable to a mutable array; `T extends readonly unknown[]` accepts both and costs nothing when the body only reads. Constraining to a domain interface like `T extends User` when the body reads only `id` blocks every DTO, mock and partial that structurally has an `id`. The cost is not just friction: rejected callers write `as User`, and that assertion is unchecked, so you have exported the unsoundness. And because constraints live in the published `.d.ts`, tightening one later is a breaking change while loosening one is usually safe — which argues for starting no tighter than the body requires.
code
typescript · 18 linesconst config = ["a", "b"] as const;
// Over-constrained: mutable array required by a function that only reads.
declare function firstMut<T extends unknown[]>(items: T): T[number];
// firstMut(config);
// TS2345: Argument of type 'readonly ["a", "b"]' is not assignable to
// parameter of type 'unknown[]'. The type is 'readonly' and cannot be
// assigned to the mutable type 'unknown[]'.
// Constrained to what the body actually needs.
declare function firstRo<T extends readonly unknown[]>(items: T): T[number];
const a = firstRo(config); // "a" | "b"
const b = firstRo([1, 2, 3]); // number
// Shape, not domain type: every DTO, fixture and mapper output fits.
function pluckId<T extends { id: string }>(x: T): string {
return x.id;
}go deeper
Take away the working rule: require only the members your function actually reads. If the body touches x.id, constrain to { id: string } rather than to a whole domain type.
Explain how the two failure directions are diagnosed — a missing-property error inside the body versus an assignability error at a call site — and give the readonly array example where an over-tight constraint rejects an as const value.
Argue the consequence rather than the aesthetics: an over-tight constraint pushes callers into unchecked assertions, so it exports unsoundness instead of preventing it. Be ready to redesign a signature under review pressure.
Own the constraint as versioned public API — loosening is compatible, tightening is breaking — and set the review rule and semver policy that keeps a shared library's constraints no tighter than its bodies require.
## The constraint is public API A type parameter's constraint is emitted into your `.d.ts`, appears in every error message your consumers read, and defines the set of callers you accept. It deserves the same care as a parameter type — and unlike an implementation detail, you cannot quietly change it later. The asymmetry that should drive the initial decision: **loosening a constraint is broadly compatible** (everything that compiled still compiles), while **tightening one breaks callers** who were relying on the wider set. If you are unsure, ship the looser one. ## The rule: constrain to what you read Write the body, then look at which members of the generic value it touches, and require exactly those. Nothing about the *intended* use case belongs in the constraint. If your `pluckId` reads `x.id`, the constraint is `{ id: string }` — not `User`, however certain you are that only `User` values will ever be passed. This produces constraints that look under-specified to reviewers used to nominal languages, and that reaction is the thing to argue past: TypeScript's assignability is structural, so a narrow constraint is not a loss of safety. It is precisely the safety you need and nothing else. ## What over-constraining actually looks like ### Mutable arrays where reading suffices ```ts declare function firstMut<T extends unknown[]>(x: T): T[number]; declare function firstRo<T extends readonly unknown[]>(x: T): T[number]; const ro = [1, 2, 3] as const; // firstMut(ro); // Error TS2345: Argument of type 'readonly [1, 2, 3]' is not assignable to // parameter of type 'unknown[]'. The type 'readonly [1, 2, 3]' is 'readonly' // and cannot be assigned to the mutable type 'unknown[]'. firstRo(ro); // ok ``` Every caller who did the right thing — froze their configuration with `as const` — is turned away by a helper that never mutates. `readonly unknown[]` accepts both mutable and readonly inputs and gives up nothing. ### A domain type where a shape suffices `T extends User` rejects the DTO from the wire, the fixture in a test, the object assembled by a mapper — all of which have an `id`. Worse, it couples your utility module to your domain module, which is a dependency you will regret when the utility moves to a shared package. ### `object` where a member suffices `T extends object` on a helper that only reads `length` rejects strings, which have one. The constraint is not tighter *in the useful direction* — it is tighter in an arbitrary one. ## Why the friction turns into unsoundness A rejected caller has three options: change their data (rarely possible), stop using your helper, or assert. In practice they assert: ```ts firstMut(ro as unknown as number[]); ``` An assertion is an unchecked claim erased at compile time, so this compiles and would be a genuine bug if the body ever mutated. Your over-tight constraint did not prevent an unsafe call — it *caused* one, and moved it somewhere you cannot see it. That is the argument that usually lands with reviewers: constraints do not make callers safer than the body is; they only decide whether the caller's workaround happens in your file or theirs. ## The other direction is real too Under-constraining has its own signature: `Property 'x' does not exist on type 'T'` inside your body, and the temptation to reach for `any` or an assertion in *your* code. The two failure modes point in opposite directions, which makes them easy to diagnose: - Error inside the body → the constraint is too loose. - Error at a call site on a value that would work at runtime → the constraint is too tight. A constraint that produces neither is correct. ## The judgment calls that remain **Deliberate narrowing.** Sometimes you want to reject values the body could technically handle — a constraint that excludes `null`-ish inputs, or one that forces a discriminant so future versions of the function can branch on it. That is legitimate; the test is whether you can name the future capability, not whether the type feels tidier. **Error-message ergonomics.** A constraint spelled as a named type produces `not assignable to 'HasId'`, which is often more readable than a sprawling inline shape. Naming the shape is free; requiring a domain type is not. **Team policy.** In a library consumed by other teams, make "is this constraint wider than the body needs" a review question, and treat any tightening as a semver-major change with a migration note. That is the difference between a constraint chosen once and a constraint you can live with.
- Give a concrete example where tightening a constraint silently rejects correct code.`T extends unknown[]` on a read-only helper. A caller who froze their configuration with `as const` gets `The type 'readonly [1, 2, 3]' is 'readonly' and cannot be assigned to the mutable type 'unknown[]'`, even though the body never mutates. Declaring `T extends readonly unknown[]` accepts both forms and costs nothing.
- How do you tell over-constraining from under-constraining when a build fails?By where the error lands. A missing-property error inside the function body means the constraint is too loose for what you are doing. An assignability error at a call site, on a value that would behave correctly at runtime, means it is too tight. The correct constraint produces neither.
- Is loosening a published constraint a breaking change?Usually not — every call that compiled before still compiles, since the accepted set only grows. Tightening is the breaking direction. The caveat is inference: widening a constraint can change what type parameters resolve to in some calls, so it still warrants a changelog entry and a check against consumer builds.
- When is a constraint tighter than the body needs still the right call?When you can name a capability it protects — a discriminant you intend to branch on in the next version, or excluding null-ish inputs so a later change does not become breaking. The test is a concrete future requirement, not that the narrower type reads better.
saying these in an interview costs you the question
- Constrains to a domain interface the body barely uses
- Treats the constraint as documentation of intended callers
- Uses unknown[] for a helper that only reads elements
- Assumes rejected callers will change their data rather than assert
- Tightens a published constraint in a patch release