skip to content

In TypeScript, when is `declare function setTheme<T extends 'light' | 'dark'>(theme: T): void` better written as `declare function setTheme(theme: 'light' | 'dark'): void`, and when does the generic version genuinely earn its keep?

level: middleimportance: should knowfreq 45%

answer

  1. what reads T here?
  2. the constraint is doing the work
  3. void return sees no difference
  4. literal type only matters downstream
  5. prefer the simpler modelling tool

basics

~20 s

In that exact shape the generic is pure noise — both signatures accept identical arguments, because T is used once. A constrained type parameter only earns its keep when another parameter or the return type is expressed in terms of T.

solid answer

~50 s

For callers those two signatures are indistinguishable: both accept `'light'` and `'dark'`, both reject `'blue'` and a plain `string` variable. The constraint is doing all the work and T is used exactly once, so the generic adds a public knob, a longer hover, and nothing else — I would take the union. The generic starts to pay when the specific member the caller passed has to travel somewhere: `setTheme<T extends 'light' | 'dark'>(theme: T): Config<T>`, or a second parameter typed in terms of T, or a conditional return. A useful side effect of the constraint is that a literal argument is inferred as its literal type rather than widened, so `T` becomes `'light'` and not `string` — but that only matters if something downstream reads T. My rule of thumb is: prefer the union until you can name the position that consumes T.

code

typescript · 17 lines
typescript
// The type parameter is used once: identical to taking the union directly.
declare function setThemeGeneric<T extends "light" | "dark">(theme: T): void;
declare function setThemeUnion(theme: "light" | "dark"): void;

setThemeGeneric("light");
setThemeUnion("light");

// Now T is read by the return type, so it earns its keep.
interface ThemeConfig<T extends string> {
  name: T;
  dark: boolean;
}

declare function makeTheme<T extends "light" | "dark">(theme: T): ThemeConfig<T>;

const cfg = makeTheme("dark");
const remembered: "dark" = cfg.name; // the literal survived the round trip

go deeper

for a junior

Know that a union of string literals already restricts a parameter to a fixed set of allowed values, and that wrapping the same union in a type parameter does not restrict it any further.

for a middle

Explain that a constrained type parameter behaves identically to its constraint when nothing reads T, and show the version that does read it — a return type such as Config<T> — as the case that justifies the generic.

for a senior

Bring the API consequences: hover text and emitted declarations, error-message quality on a hot API, the ability of callers to pass explicit type arguments, and how narrowing inside the body is easier with a union than with a type parameter.

for a principal

Frame it as a design default for the codebase — concrete unions in published signatures, type parameters only where a caller-visible relationship exists — and connect it to how easily those signatures can evolve later.

## Two signatures, one behaviour ```ts declare function setThemeA<T extends "light" | "dark">(theme: T): void; declare function setThemeB(theme: "light" | "dark"): void; setThemeA("light"); // ok setThemeB("light"); // ok // setThemeA("blue"); // error // setThemeB("blue"); // error ``` Every call accepted by one is accepted by the other, and both return `void`. That is the whole test. A constrained type parameter used once behaves exactly like its constraint written directly in that position, because the only thing the compiler does with T is check the argument against the constraint and then throw the solved type away. So the cost/benefit is one-sided. The costs of the generic form are real, if small: an extra public knob (a caller can write `setThemeA<"light">(x)` and change nothing), a noisier signature in tooltips and generated `.d.ts` files, an extra thing to maintain when the union changes, and a mildly worse error message, since the union form reports the argument directly against a list of allowed values. ## What the constraint changes about inference There is one genuinely different mechanic worth knowing, even though it does not by itself justify the generic. When a type parameter is constrained to a primitive-ish type such as `string`, a literal argument is inferred as its **literal** type rather than widened: ```ts declare function idA<T extends string>(s: T): T; declare function idB<T>(s: T): T; const a = idA("go"); // a: "go" const b = idB("go"); // b: string ``` That difference is only observable because T is *read* by the return type. In a `void` function nobody can see which type was inferred, so it makes no difference at all. This is the cleanest way to state the whole rule: an inference nicety matters exactly when some other position consumes the result. ## When the generic earns its keep Three shapes justify a type parameter over a plain union: **1. The return type depends on which member was passed.** ```ts interface ThemeConfig<T> { name: T; dark: boolean } declare function makeTheme<T extends "light" | "dark">(theme: T): ThemeConfig<T>; const c = makeTheme("dark"); // c: ThemeConfig<"dark"> ``` The caller gets back a type that remembers what they asked for. With the union parameter the best you could return is `ThemeConfig<"light" | "dark">`, which loses information. **2. Two positions must agree.** If a second parameter is typed as `Options[T]` or `(value: T) => void`, the type parameter is the thing linking them, and no union can express that relationship. **3. Element-type transport.** The same argument applies to non-literal unions: `pick<K extends keyof O>` style APIs exist because the key must reach the return type. ## When the union is strictly better - The function returns `void`, `boolean`, or any type that does not mention the parameter. - The allowed values are a fixed, small, closed set with no per-member behaviour in the types. - You want to **prevent** a caller from widening or narrowing the accepted set through an explicit type argument. - You want dead-simple error messages and hover text on a widely used API. Unions also compose better with narrowing: a value of a union type can be discriminated with `typeof`, equality checks, or a `switch`, and the checker follows that flow inside the function body. A type parameter cannot be narrowed the same way, because inside the generic body the parameter is only known through its constraint. ## A related mistake: the over-constrained parameter The mirror image of this smell is a type parameter whose constraint is far tighter than the body needs, for example `<T extends { id: string; createdAt: Date; owner: User }>` when the function only reads `id`. That rejects perfectly good callers for no reason. If T is genuinely used elsewhere, constrain it to the least the code requires — `<T extends { id: string }>` — and if T is not used elsewhere, drop the parameter and take `{ id: string }` directly. ## The review question When you see `<T extends SomeUnion>(x: T)`, ask a single question: **which other position reads T?** If you can point at one — a return type, another parameter, a nested type argument — the generic is doing work. If you cannot, delete the angle brackets and put the union in the parameter position. The signature gets shorter, the error messages get better, and no caller loses anything.

  • Does the generic form behave differently when the union is made of object types rather than string literals?
    Yes, in one visible way: passing a fresh object literal to `(x: Shape)` triggers excess property checking, while `<T extends Shape>(x: T)` infers T as the literal's own type, satisfies the constraint, and lets the extra property through. That is another reason the union parameter is often the stricter, better choice.
  • If I need a literal type preserved for the return but hate the constrained-generic noise, is there another option?
    Yes — overloads, or a `const` type parameter when you need literal inference through arrays and objects. But for a single scalar the constrained generic is the idiomatic form; the noise complaint only applies when nothing reads T. When something does read it, the signature is earning its length.
  • How does this rule apply to a function that takes a union and returns different types per member?
    That is the strongest case for a type parameter, usually paired with a conditional or indexed return type such as `Result[T]`. The union parameter cannot express per-member results, so it would force the caller to narrow the returned union afterwards — which is exactly the information loss the generic prevents.

saying these in an interview costs you the question

  • A generic is stricter than a union of literals
  • Adding extends makes the function more reusable
  • Union parameters lose autocomplete in editors
  • Every parameter with a fixed set of values should be generic
  • The generic version rejects a wider range of arguments

context