In TypeScript, a helper declared `function track<T extends { id: string }>(x: T): T` could instead declare its return type as the constraint, `{ id: string }`. What difference does that make to callers, and which should you prefer?
answer
- constraint is a floor, not a ceiling
- information in, information out
- extras survive at runtime, not in the type
- callers reach for as to recover the shape
- T in the return position, or nothing
basics
~20 sReturning T preserves the caller's exact type, so extra properties survive the call. Returning the constraint widens every result to { id: string } and silently discards the rest. Prefer T — carrying the caller's type through is the reason the generic exists.
solid answer
~50 sThe constraint is the floor for what the *body* may rely on, not the ceiling for what the *caller* gets back. If the return type is `T`, inference resolves `T` to the argument's own type, so `track({ id: "u1", email: "[email protected]" })` gives back `{ id: string; email: string }`. If the return type is `{ id: string }`, every call collapses to that shape and reading `.email` on the result fails with `Property 'email' does not exist on type '{ id: string; }'` — the extra property is still there at runtime, the type just threw the information away. That widening is the classic reason a generic looks pointless: the type parameter goes in, the specificity does not come out, and callers start writing `as` assertions to recover what you erased. Return the constraint only when the function genuinely builds a fresh value of that fixed shape — and then the type parameter is probably not needed on the return at all.
code
typescript · 19 linesdeclare function trackT<T extends { id: string }>(x: T): T;
declare function trackC<T extends { id: string }>(x: T): { id: string };
const user = { id: "u1", email: "[email protected]" };
const a = trackT(user); // { id: string; email: string }
const b = trackC(user); // { id: string }
console.log(a.email);
// console.log(b.email);
// Error TS2339: Property 'email' does not exist on type '{ id: string; }'.
// Augmenting the input without losing it:
function withRevision<T extends { id: string }>(x: T): T & { rev: number } {
return { ...x, rev: 0 };
}
const r = withRevision(user);
console.log(r.email, r.rev);go deeper
Recall that a constraint says what the body is allowed to use, not what the caller receives. Given the two signatures, say which one lets the caller keep reading the extra property.
Walk through the inferred types on both sides and name the exact call-site error the widening version produces. Explain that the object is unchanged at runtime and only the type is lost.
Show the downstream cost: erased literal and readonly modifiers, compounding loss across chained helpers, and callers writing assertions that reintroduce unsoundness. Know the T & { … } pattern for augmenting an input.
Frame generic signatures as contracts about information flow and make "type parameter absent from the return type" a review trigger, since fixing it after publication changes inferred types across every consumer.
## The two roles a constraint plays `T extends { id: string }` does exactly two things: 1. **At the call site** it filters which types a caller may substitute for `T`. 2. **Inside the body** it tells the checker the minimum shape every `T` has, so `x.id` is legal. What it does *not* do is change what `T` is inferred as. Inference still picks the argument's own type. The constraint is a lower bound on `T`, not a replacement for it. ## The concrete difference ```ts declare function trackT<T extends { id: string }>(x: T): T; declare function trackC<T extends { id: string }>(x: T): { id: string }; const user = { id: "u1", email: "[email protected]" }; const a = trackT(user); // { id: string; email: string } const b = trackC(user); // { id: string } a.email; // ok // b.email; // Error TS2339: Property 'email' does not exist on type '{ id: string; }'. ``` At runtime `a` and `b` are the same object. The difference is purely in the type layer — and that is precisely why it is easy to ship and hard to notice until a caller complains. ## Why the widening version is a bug, not a style choice When the return type is the constraint, the function acts as a **type eraser** sitting in the middle of a data flow. Everything downstream of it is poorer than the code that called it: - Property access on the result breaks even though the property exists. - Callers work around it with `as` assertions (`trackC(user) as typeof user`), which relocates an unchecked claim into user code and defeats the point of typing the helper at all. - The loss compounds in pipelines: chain three such helpers and the last stage sees only `{ id: string }`. - Literal types and `readonly` modifiers vanish too. `trackT({ id: "x" } as const)` yields `{ readonly id: "x" }`; the constraint-returning version yields `{ id: string }`. The symptom callers report is "why is this generic" — and they are right: a type parameter that appears in the parameter list but not the return type carries information *in* and lets none *out*. ## When returning the constraint is correct There is a legitimate case: the function does not hand back its input, it **builds a fresh value** of a fixed shape. ```ts function summarize<T extends { id: string; createdAt: number }>( x: T, ): { id: string; age: number } { return { id: x.id, age: Date.now() - x.createdAt }; } ``` Here the concrete return type is honest — the result really is only that shape. Note what changed: the return type is a *different* type, chosen deliberately, not the constraint reused out of habit. The type parameter still earns its place by letting the caller pass any object with those two fields. ## Preserving *part* of the caller's type When you want to add to the input rather than replace it, keep `T` in the return type and intersect: ```ts function withRevision<T extends { id: string }>(x: T): T & { rev: number } { return { ...x, rev: 0 }; } const r = withRevision({ id: "u1", email: "[email protected]" }); r.email; // ok r.rev; // ok ``` The spread of a generic value is typed as `T & { rev: number }`, which is assignable to the declared return type, so this compiles without an assertion. ## The rule to carry into review Read every generic signature as a sentence about information flow: *what does the caller know before the call that it should still know after?* If the answer is "the exact type it passed in", the return type must mention `T`. If a reviewer can delete the type parameter from the return type and nothing at any call site breaks, the parameter was decoration — that is the diagnostic, and the fix is nearly always to put `T` back in the return position rather than to remove the generic. ## A note on the direction of the error If you try to widen deliberately and hit `Type 'T' is not assignable to type '{ id: string }'`, something else is wrong — `T` is always assignable to its own constraint, so an error in that direction means the declared type is not actually the constraint. The failure that *does* happen constantly is the reverse: assigning a constraint-shaped value where a `T` is expected, which the compiler rejects because the caller chose `T` and may have chosen a narrower type.
- How would you type a helper that adds a property to the caller's object without losing the original type?Keep the type parameter in the return type and intersect: `function withRevision<T extends { id: string }>(x: T): T & { rev: number }`. Spreading a generic value produces exactly `T & { rev: number }`, so the body compiles without an assertion, and callers keep every property they passed in.
- A reviewer says a generic "does nothing". What in the signature tells you they are right?The type parameter appearing in the parameter list but nowhere in the return type, with the body relying only on the constraint. Nothing at any call site changes if you replace `T` with the constraint outright — information goes in and none comes out, which is the same defect as returning the constraint.
- Does returning the constraint change what the function does at runtime?No. Types are erased, so the object handed back is identical either way, extra properties included. The difference exists only in the checker, which is why the loss ships quietly: nothing crashes, callers just cannot see fields that are demonstrably present.
saying these in an interview costs you the question
- Thinks the constraint is what T becomes inside the function
- Says extra properties are stripped at runtime
- Recovers the lost shape with an as assertion at every call site
- Claims returning T is unsafe because T is unknown to the body
- Believes a generic must appear in the return type to compile