FullStack Course LogoFullStack Course
Module: TypeScript
TypeScript·165·11 MIN READ

165: Generics, Constraints, Defaults, Inference, and Variance

TOPICS COVERED: Generics, Constraints, Defaults, Inference, and Variance

Learning outcomes

By the end of this lesson, you can:

  • explain and apply generic functions in a realistic implementation;
  • explain and apply generic constraints in a realistic implementation;
  • explain and apply keyof constraints in a realistic implementation;
  • explain and apply default type parameters in a realistic implementation;
  • explain and apply inference positions in a realistic implementation.

Prerequisites and retrieval

This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before you start, retrieve one concrete example from a previous project where this kind of problem appeared. Perhaps a helper lost the relationship between its input and output types, or a callback accepted a value it could not safely handle. The point is not to memorize terminology. It is to make a defensible choice in a strict TypeScript codebase, where the compiler helps model domain invariants but does not replace runtime validation.

Terminology

  • Generic functions: function first<T>(items: readonly T[]): T | undefined preserves the element type from input to output. A User[] therefore produces a User | undefined, not an unrelated broad type.
  • Generic constraints: T extends Constraint limits the substitutions that are allowed while retaining the caller's more specific subtype. The constraint describes what the implementation needs; it does not erase all of the subtype's information.
  • keyof constraints: Patterns such as <T, K extends keyof T> tie a key argument to one particular object type. They also allow the indexed return type to stay precise rather than becoming a broad union.
  • Default type parameters: Defaults reduce call-site noise when one generic choice is the normal choice, while still allowing callers to select an alternative. A default is convenience, not a substitute for a decision that must remain explicit.
  • Inference positions: TypeScript infers type arguments from function arguments, contextual return positions, and constraints, although each position has limits. Understanding where evidence comes from explains many surprising inferences.
  • Variance: Variance describes how subtype relationships flow through generic containers or callbacks. A type that produces values has different safety requirements from one that consumes them, and a mutable type can often do both.

Mental model

Treat Generics, Constraints, Defaults, Inference, and Variance as one design problem: identify the observable inputs and outputs, state the invariant, and then examine the ways the design can fail. Generics are valuable when they preserve a relationship between values. If a type parameter does not communicate a useful relationship, it may be unnecessary abstraction rather than extra safety.

A strong implementation makes its assumptions visible, narrows uncertainty at the system boundary, and leaves evidence that the design is safe. That evidence might be tests, types, constraints, metrics, or diagrams. The type system can prove some relationships at compile time; it cannot validate malformed JSON, enforce authorization, or guarantee that a database still contains the value your code expected.

A useful sequence for both production work and interviews is:

text
requirement -> constraints -> model -> implementation -> failure analysis -> verification

Do not jump from a requirement directly to a library call or a clever generic signature. First state what must remain true. Then choose the mechanism that enforces that condition, and identify what still has to be checked at runtime.

Deep dive

1. Generic functions

Suppose a helper returns the first item in a list. function first<T>(items: readonly T[]): T | undefined preserves the element type from input to output. Calling it with User[] gives the caller User | undefined. Returning unknown would force the caller to narrow information the function already had, while returning any would discard the safety boundary entirely.

The useful distinction is between preserving a relationship and merely making a function look reusable. A generic function is a good fit when the output type depends on the input type in a way the compiler can express.

Decision rule: Use generic functions deliberately when they make the contract or invariant easier to prove. If a generic only reduces a few keystrokes while hiding an assumption, prefer the more explicit design.

2. Generic constraints

An unconstrained type parameter tells the implementation very little. If the function needs to use a property or operation, express that requirement with T extends Constraint. The constraint limits valid substitutions while retaining the specific subtype supplied by the caller. Constrain only what the implementation actually needs, such as PropertyKey or an object with an id; an overly broad constraint can make the API harder to use, and an overly narrow one can reject valid callers.

Decision rule: Use generic constraints deliberately when they make the contract or invariant easier to prove. If the constraint only hides an assumption or creates an artificial restriction, make the required input more explicit instead.

3. keyof constraints

A function that accepts an object and one of its keys should not accept an arbitrary string. The pattern <T, K extends keyof T> connects K to the keys of the particular T passed to the function. That relationship lets an indexed return type remain precise: selecting a name key from a User can produce the type of User["name"], rather than User[keyof User] or unknown.

This is where people usually get confused: keyof T is not the set of keys from every possible object. It is calculated for the specific type represented by T. Runtime input still needs validation if the object or key came from an untrusted source.

Decision rule: Use keyof constraints deliberately when they make the contract or invariant easier to prove. If callers cannot know the key until runtime, keep the runtime validation and do not pretend that a type assertion has performed it.

4. Default type parameters

Defaults are useful when one generic choice is dominant but alternatives are supported. They allow the common call site to omit a type argument without removing the ability to provide one. This is particularly helpful for public helpers and configuration-shaped APIs where a standard result or option type is used most of the time.

The limit is important: a default does not resolve uncertainty in the data. If callers need to choose between materially different meanings, make that choice visible rather than letting a convenient default hide it.

Decision rule: Use default type parameters deliberately when they make the contract or invariant easier to prove. If they only conceal an assumption callers need to review, require the type choice explicitly.

5. Inference positions

TypeScript gathers evidence from several places. Function arguments are usually the clearest source; contextual return positions can influence the expected type; and constraints limit which inferred candidates are valid. Inference is useful, but it has limits when a type parameter appears only in a return position, appears in conflicting positions, or is separated from the value by a complex wrapper.

When inference becomes difficult to read or maintain, a small helper function or an explicit type argument may be better than forcing callers to spell out a complicated generic expression. The goal is not to maximize inference. It is to make the intended relationship obvious and stable.

Decision rule: Use inference positions deliberately when they make the contract or invariant easier to prove. If the inferred result is surprising or fragile, expose the relevant type information through a clearer API.

6. Variance

Variance describes how subtype relationships flow through generic containers or callbacks. A producer of Dog values can often be used where a producer of Animal values is expected, because every Dog is an Animal. A consumer has the opposite safety pressure: a callback that can handle any Animal can handle a Dog, but a callback that handles only one specific Dog cannot safely stand in for a callback promised to handle every Animal.

Mutable containers are especially easy to misclassify because they both produce and consume values. Avoid calling a type covariant when it can also accept values; otherwise a caller may write a valid value for the wider type into a container that another part of the program treats as narrower.

Decision rule: Use variance deliberately when it makes the contract or invariant easier to prove. If a type both reads and writes values, model that mutability explicitly and do not rely on a one-directional subtype relationship.

Worked example

Consider a strict TypeScript codebase where the compiler models domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts and identify which of the concepts above owns each failure mode.

The key design move is separation of responsibilities. Parsing and validation belong at the boundary. Domain rules belong in the domain or service layer. Persistence rules belong in the database or repository. Presentation rules belong in the client. Mixing those concerns can make a happy-path demo shorter, but it makes edge cases, retries, and failures much harder to reason about.

Here is a small generic helper that indexes items by a derived property key:

ts
function indexBy<T, K extends PropertyKey>(
  items: readonly T[],
  keyOf: (item: T) => K
): Record<K, T> {
  return Object.fromEntries(items.map(item => [keyOf(item), item])) as Record<K, T>;
}

T preserves the item type, while K extends PropertyKey states exactly what JavaScript object keys may be. The callback makes key extraction explicit and allows the caller to derive a key without requiring every item to have one fixed property. The Record<K, T> result describes the intended index, but it does not prove at runtime that every key is unique or that the input contains the shape the application expects. The assertion therefore does not remove the need for validation and tests.

Walk through at least four cases: the normal path; an empty input or missing value; a duplicate, retry, or concurrent path where that scenario is relevant; and a dependency failure. For each case, state which layer detects the problem and what the caller observes. For example, duplicate keys may overwrite earlier entries in this implementation, so the requirement must say whether that is acceptable. This is the level of explanation expected in a senior code review or technical interview.

Production perspective

Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failures, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality workloads. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after evidence identifies a bottleneck or a concrete risk.

When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume that the client can be modified and that network input is untrusted. TypeScript annotations help at compile time; they do not make data received over the network trustworthy.

Guided lab

Create a generic repository interface, a groupBy helper constrained to property keys, and a typed event bus. Demonstrate one variance mistake using a mutable container or callback, then redesign it safely.

Complete the lab with this discipline:

  1. Write the requirement and two non-requirements.
  2. List input, output, and error contracts before implementation.
  3. Implement the smallest correct vertical slice.
  4. Add at least one invalid-input test and one edge-case test.
  5. Instrument or inspect the behavior instead of guessing.
  6. Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
  7. Explain one alternative design and why you did not choose it.
  8. Record a short “what would break at 10× scale?” note.

Edge cases and failure modes

  • Generic functions: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
  • Generic constraints: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
  • keyof constraints: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
  • Default type parameters: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
  • Inference positions: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.

For variance, include the additional question of direction: can the type safely produce the value, consume the value, or do both? That distinction often reveals an unsafe assignment that a superficial subtype check misses.

Common mistakes and debugging

  • Solving the example instead of the requirement: a copied pattern may be syntactically correct while being architecturally wrong for the actual invariant.
  • Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary” any values.
  • Testing only the happy path and discovering the real contracts only after integration.
  • Optimizing before measuring, or choosing a scalable mechanism without a scale requirement.
  • Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.

For debugging, reproduce the smallest failing case first. Inspect the actual value, inferred type, emitted JavaScript, or execution plan as appropriate. Trace the boundary where the invariant first becomes false, then fix the layer that owns that invariant instead of adding a downstream patch. In a generic API, inspect both the type arguments the caller supplied or inferred and the runtime values that crossed the boundary; they are related, but they are not the same evidence.

Interview questions

  1. What problem do generic functions solve, and what trade-off or failure mode would make you choose a different approach?
  2. What problem do generic constraints solve, and what trade-off or failure mode would make you choose a different approach?
  3. What problem do keyof constraints solve, and what trade-off or failure mode would make you choose a different approach?
  4. What problem do default type parameters solve, and what trade-off or failure mode would make you choose a different approach?
  5. What problem do inference positions solve, and what trade-off or failure mode would make you choose a different approach?

Checkpoint

Without notes, explain Generics, Constraints, Defaults, Inference, and Variance to another developer in five minutes. Include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.

Mastery checklist

  • I can define the core terms precisely.
  • I can choose a design from requirements instead of from habit.
  • I can implement and test the normal path and edge cases.
  • I can explain the runtime, storage, or complexity cost.
  • I can identify which layer owns validation, errors, and recovery.
  • I can compare at least two reasonable alternatives.
  • I can explain how the design changes at larger scale or stricter reliability.

References

Reader page: /typescript/lesson/165/generics-constraints-defaults-inference-and-variance