skip to content

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?

level: principalimportance: should knowfreq 34%

answer

  1. constrain to what you read
  2. the constraint is published API
  3. loosening is safe, tightening breaks callers
  4. as const versus mutable array parameters
  5. rejected callers assert, and assertions lie

basics

~20 s

Constrain 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 s

My 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 lines
typescript
const 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context