Skip to content

Generics and Type Relationships

A generic describes a relationship that remains true across multiple types. The point is not merely to accept “anything”; unknown already does that. A generic preserves information from one position to another.

ts
function first<T>(values: readonly T[]): T | undefined {
  return values[0];
}

const name = first(["Ada", "Lin"]); // string | undefined
const count = first([1, 2, 3]);      // number | undefined

The caller normally does not write T. TypeScript infers it from the argument, and the return type stays connected to that input.

Compare a function that genuinely needs no relationship:

ts
function logValue(value: unknown): void {
  console.log(value);
}

Writing logValue<T>(value: T) would add a type parameter without providing useful information to caller or implementation.

Multiple positions reveal the purpose

ts
function mapValues<Input, Output>(
  values: readonly Input[],
  transform: (value: Input, index: number) => Output,
): Output[] {
  return values.map(transform);
}

Input connects the array to the callback parameter; Output connects the callback result to the returned array. Names such as Input and Output are clearer than T and U once relationships become nontrivial.

Generic constraints state required capabilities:

ts
function indexById<Item extends { id: PropertyKey }>(
  items: readonly Item[],
): Map<Item["id"], Item> {
  return new Map(items.map(item => [item.id, item]));
}

Inside the function, Item is known to have id, while its remaining structure is preserved for callers.

A key parameter can be tied to the selected object:

ts
function getProperty<ObjectType, Key extends keyof ObjectType>(
  object: ObjectType,
  key: Key,
): ObjectType[Key] {
  return object[key];
}

const transaction = { id: "t-1", amount: 42 };
const amount = getProperty(transaction, "amount"); // number

This is much more informative than returning unknown, and safer than accepting any string.

Generic containers model reusable state

ts
type Result<Value, Failure = Error> =
  | { ok: true; value: Value }
  | { ok: false; error: Failure };

async function attempt<Value>(
  operation: () => Promise<Value>,
): Promise<Result<Value>> {
  try {
    return { ok: true, value: await operation() };
  } catch (error) {
    return {
      ok: false,
      error: error instanceof Error ? error : new Error(String(error)),
    };
  }
}

The generic describes what a successful operation produces. The discriminant describes which fields are available. Runtime try/catch still performs the actual recovery.

Default type arguments, such as Failure = Error, are useful when one choice dominates but callers occasionally need another. Too many defaulted parameters can make an API difficult to read.

Conditional types compute type relationships

ts
type ElementOf<T> = T extends readonly (infer Element)[] ? Element : T;

type A = ElementOf<string[]>; // string
type B = ElementOf<number>;   // number

infer names a type discovered while matching a structure. Conditional types distribute over unions when the checked value is a bare type parameter, which is useful but sometimes surprising:

ts
type Wrapped<T> = T extends unknown ? { value: T } : never;
type Example = Wrapped<string | number>;
// { value: string } | { value: number }

The standard library uses these tools for Awaited<T>, ReturnType<F>, Parameters<F>, Exclude, and Extract. Application code should favor readable domain models over elaborate type puzzles. If a type requires a long explanation and saves little caller code, a simpler explicit union may be better.

Overloads describe distinct call forms

When return type depends on genuinely different call signatures, overloads can expose them:

ts
function parseInput(value: string): string[];
function parseInput(value: Uint8Array): string[];
function parseInput(value: string | Uint8Array): string[] {
  const text = typeof value === "string"
    ? value
    : new TextDecoder().decode(value);
  return text.split("\n");
}

The implementation signature must handle every overload but is not directly callable. Prefer a union parameter when all callers receive the same return relationship; overloads are most valuable when the public call forms differ materially.

Variance appears at callback boundaries

Suppose Dog extends Animal. A function that can handle every Animal is safe wherever a dog handler is needed, but a dog-only handler is unsafe where it may receive an arbitrary animal:

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

Under strict function checking, parameter types reflect this direction. Variance terminology can sound abstract, but the practical question is concrete: who produces the value, and who consumes it? Producers can often become more specific; consumers can often become more general.

Mutable containers complicate relationships because they both produce and consume values. readonly arrays remove mutation operations and permit safer, more flexible inputs:

ts
function countAnimals(animals: readonly Animal[]) {
  return animals.length;
}

Accept readonly collections when a function does not need to mutate them. It documents the contract and avoids rejecting readonly callers.

Design from caller behavior

Good generic APIs usually require few explicit type arguments:

ts
const grouped = groupBy(transactions, transaction => transaction.currency);

If callers constantly write angle-bracket annotations or assertions, inference may lack the right evidence. Place type parameters on inputs from which they can be inferred, return precise values, and avoid type parameters used only once.

Generics are a vocabulary for relationships already present in the program. They are most successful when they make an ordinary implementation feel obvious rather than when they maximize type-system cleverness.

Further reference