Generics are often introduced as syntax for replacing any: put a type parameter between angle brackets, reuse it in a few positions, and the function becomes “type safe.” That description misses the hard part. A useful generic API must preserve relationships between values, accept exactly the substitutions its implementation can handle, and infer useful types without forcing callers to annotate every expression.

Variance is the vocabulary for those substitutions. Constraints describe the capabilities a type parameter must have. Inference determines how much information survives a call. These concerns meet at API boundaries: collections, callbacks, event handlers, serializers, repositories, and state containers. Getting one wrong can make a signature needlessly rigid or, worse, make it claim a safety guarantee that the runtime cannot keep.

The examples below assume strict: true, especially strictFunctionTypes. Without strict checking, parameter compatibility becomes more permissive and several important distinctions disappear.

Constraints Should Preserve Information

A constraint is an upper bound, not a replacement type. Compare a function that accepts a broad interface with one that captures the caller’s exact type:

ts
type Identified = { id: string };

function indexByIdLoose(values: Identified[]): Map<string, Identified> {
  return new Map(values.map((value) => [value.id, value]));
}

function indexById<T extends Identified>(values: readonly T[]): Map<string, T> {
  return new Map(values.map((value) => [value.id, value]));
}

const users = [
  { id: 'u1', name: 'Ada', role: 'admin' as const },
  { id: 'u2', name: 'Lin', role: 'reader' as const },
];

const loose = indexByIdLoose(users).get('u1');
// Identified | undefined: name and role were erased.

const precise = indexById(users).get('u1');
// { id: string; name: string; role: 'admin' | 'reader' } | undefined

The implementation only needs id, so T extends Identified grants that capability while preserving every additional field. The readonly T[] parameter also says the function reads the input but does not mutate it, allowing both mutable and readonly arrays at the call site.

Constraints become particularly effective when they express a relationship between parameters:

ts
function pluck<T, K extends keyof T>(
  values: readonly T[],
  key: K,
): Array<T[K]> {
  return values.map((value) => value[key]);
}

const names = pluck(users, 'name'); // string[]
const roles = pluck(users, 'role'); // Array<'admin' | 'reader'>
// pluck(users, 'missing');          // Error: not a key of the element type.

K extends keyof T does more than reject a bad string. It connects the selected key to the return type through T[K]. A signature such as (values: object[], key: string) => unknown[] can perform the same runtime loop, but throws away the relationship callers need.

Inference can still widen literals when a value must fit a mutable position. Use as const or a const type parameter when retaining literal structure is part of the contract:

ts
function defineRoutes<const T extends readonly string[]>(routes: T): T {
  return routes;
}

const routes = defineRoutes(['/users', '/settings']);
// readonly ['/users', '/settings']

Do not add type parameters that express no relationship. A function parse<T>(text: string): T does not infer or validate T; it merely lets the caller assert a desired result. Return unknown, validate it, and narrow it instead.

Note

Use unknown when a value exists but its type is not yet established. Operations on it require narrowing. any disables checking and is contagious through expressions, so it belongs at narrowly contained interop boundaries, followed by validation. A generic type parameter is not a safer spelling of any unless the implementation can actually uphold the caller-selected type.

Variance Is About Safe Substitution

Suppose Admin is a subtype of User. A generic constructor Box<T> is covariant if Box<Admin> can be used as Box<User>. It is contravariant if the direction reverses: Handler<User> can be used as Handler<Admin>. It is invariant if neither substitution is generally safe.

flowchart LR A[Admin] -->|subtype of| U[User] RA[Producer of Admin] -->|covariant substitution| RU[Producer of User] HU[Consumer of User] -->|contravariant substitution| HA[Consumer of Admin] BA[Mutable Box of Admin] -. neither direction .- BU[Mutable Box of User]

The deciding question is where T appears. A producer returns T; a consumer accepts T; a mutable container usually does both.

Shape Position of T Safe direction Typical example
Producer Output Covariant () => T, ReadonlyArray<T>
Consumer Input Contravariant (value: T) => void
Producer and consumer Input and output Invariant Mutable cell or channel
Phantom Not used structurally Independent/bivariant Marker metadata, often a design smell

TypeScript infers variance from structural use. It also supports out T, in T, and in out T annotations on type aliases and interfaces, but they are advanced checking and performance tools rather than a substitute for an honest structure. Most application code is clearer when the members themselves reveal whether a type produces or consumes T.

ts
interface Producer<out T> {
  get: () => T;
}

interface Consumer<in T> {
  accept: (value: T) => void;
}

interface Cell<in out T> {
  get: () => T;
  set: (value: T) => void;
}

Producer<Admin> is usable where Producer<User> is required: every produced admin is a user. Consumer<User> is usable where Consumer<Admin> is required: it already knows how to accept any user, including admins. A Cell<Admin> cannot safely become a Cell<User>, because someone could then store an ordinary user in it. The reverse direction cannot promise that reads are admins.

Callbacks Reverse the Direction

Callback APIs are where contravariance becomes practical rather than academic. A callback capable of handling every User can safely handle a stream containing only Admin values. A callback that only understands Admin cannot handle an arbitrary User.

ts
type User = { id: string };
type Admin = User & { permissions: readonly string[] };

type Handler<T> = (value: T) => void;

const handleUser: Handler<User> = (user) => {
  console.log(user.id);
};

const handleAdmin: Handler<Admin> = (admin) => {
  console.log(admin.permissions.join(', '));
};

let adminHandler: Handler<Admin> = handleUser; // Safe.
// let userHandler: Handler<User> = handleAdmin; // Error under strictFunctionTypes.

This direction often feels backward because assignment is about the function’s capability, not the parameter’s inheritance arrow. Ask what values the eventual caller may pass. A Handler<User> slot may receive a plain user, so an admin-only function is too narrow.

There is an important exception. For historical compatibility with common JavaScript class and DOM patterns, TypeScript checks method parameters bivariantly. Method syntax can therefore accept either direction where a function property would reject an unsafe one:

ts
interface UnsafeListener<T> {
  onValue(value: T): void; // Method parameter: bivariant compatibility.
}

interface StrictListener<T> {
  onValue: (value: T) => void; // Function property: contravariant.
}

declare let adminOnlyMethod: UnsafeListener<Admin>;
const acceptsUsers: UnsafeListener<User> = adminOnlyMethod; // Allowed.

// Runtime danger: acceptsUsers.onValue({ id: 'u1' }) may reach code
// that assumes permissions exists.

Warning

Method bivariance is an intentional soundness compromise, not proof that the assignment is safe. For public callback contracts where parameter direction matters, prefer function-valued properties such as onValue: (value: T) => void. Keep strictFunctionTypes enabled.

Callbacks nested inside another function deserve the same analysis. In an API like subscribe<T>(source: Source<T>, observer: (value: T) => void), the observer consumes values. Do not constrain it to the source’s exact inferred subtype if a broader callback is valid. Conversely, never accept a narrower callback merely because today’s implementation happens to emit one subtype.

Readonly Improves Both Safety and Reuse

Mutable arrays expose one of TypeScript’s best-known unsound corners. The compiler permits an Admin[] to be assigned to User[], largely for ergonomic and historical reasons. Mutation can then violate the original array’s element promise:

ts
const admins: Admin[] = [
  { id: 'a1', permissions: ['deploy'] },
];

const usersView: User[] = admins; // Allowed by TypeScript.
usersView.push({ id: 'u2' });      // Inserts a non-admin.

admins[1]?.permissions.join(', '); // Runtime failure if accessed unchecked.

This is not sound covariance. It is a deliberate tradeoff in the language. ReadonlyArray<T> removes mutating operations, making covariance defensible because consumers can only observe elements:

ts
function firstId(values: readonly User[]): string | undefined {
  return values[0]?.id;
}

const readonlyAdmins: readonly Admin[] = admins;
firstId(readonlyAdmins); // Safe: the function cannot insert a User.

Mark input collections readonly whenever an implementation only reads them. This both documents intent and widens the set of valid callers. For an actual mutable abstraction, avoid pretending it is covariant. Split capabilities instead:

ts
interface ReadableStore<out T> {
  read: () => T;
}

interface WritableStore<in T> {
  write: (value: T) => void;
}

interface Store<T> extends ReadableStore<T>, WritableStore<T> {}

Clients that only read receive ReadableStore<T> and gain safe covariance. Writers receive the contravariant capability. Only code that genuinely needs both gets the invariant Store<T>. Capability-oriented interfaces are easier to substitute, mock, and evolve than one large mutable interface.

Type assertions can bypass all of these checks, as can any, unchecked indexed access, and inaccurate declaration files. TypeScript aims for useful static guarantees over JavaScript, not full soundness. Good API design reduces the number of escape hatches and validates data where static knowledge ends, especially at network, storage, and JSON.parse boundaries.

Design Inference Before Adding Overloads

Overloads are useful when return types change in genuinely discrete ways, but they can hide relationships and produce surprising inference. Callers see only overload signatures; the implementation signature is not callable from outside.

ts
function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
  return typeof value === 'number' ? value.toFixed(2) : value.trim();
}

const input: string | number = Math.random() > 0.5 ? 'ok' : 42;
// format(input); // Error: no single overload accepts the union.

If both branches have the same output, one union signature is more composable: format(value: string | number): string. If output is related to input, a generic or conditional type may express that relationship. Also remember that type utilities inspecting an overloaded function generally infer from its last signature, not by replaying overload resolution for every call.

Do not make inference solve contradictory jobs. If one argument should determine T while another should only be checked against it, NoInfer<T> can block the second inference source:

ts
function choose<T>(options: readonly T[], fallback: NoInfer<T>): T {
  return options[0] ?? fallback;
}

choose(['red', 'green'] as const, 'red');
// choose(['red', 'green'] as const, 'blue'); // Error.

A practical review checklist for a generic API is short:

  1. Name the relationship each type parameter represents; remove parameters used only once unless they constrain another type.
  2. Give constraints the minimum capability the implementation requires.
  3. Preserve caller information in the return type instead of widening to the constraint.
  4. Mark read-only inputs as readonly and split reading from writing where possible.
  5. Check every callback parameter under strictFunctionTypes; use function properties when method bivariance would weaken the contract.
  6. Prefer a union or one relational generic over a long overload list.
  7. Keep unknown at untrusted boundaries, validate it, and isolate unavoidable any or assertions.

Takeaways

Generics are valuable because they preserve relationships, not because angle brackets look more precise than any. Constraints should expose only the operations an implementation needs while retaining the caller’s concrete type. Inference should flow from authoritative inputs, and overloads should not replace a relationship that one signature can express.

Variance then answers whether one generic instantiation can safely stand in for another. Producers are naturally covariant, consumers contravariant, and mutable producer-consumers invariant. TypeScript intentionally relaxes this model for mutable arrays and method parameters, so knowing the sound model matters even when the compiler accepts more.

The most robust APIs make their capabilities visible: readonly inputs, separate read and write interfaces, function-valued callbacks, narrow constraints, and validated unknown at runtime boundaries. Those choices do more than satisfy the type checker. They tell maintainers what substitutions the implementation can actually honor.