← TypeScript: types, contracts and diagnosis
05 / 10 · 60 MIN

Generics and key relationships

Use generics to retain relationships between inputs and results; distinguish constraints, known keys and data validation.

A useful relationship, not a label

Imagine a function receiving a record and returning that same record after placing it in a local queue. An object return loses concrete fields; an any return loses checking. A T parameter used for input and return retains the relationship without requiring a function version for each model.

T neither identifies a runtime class nor produces a copy. The function must work for every type admitted by its signature. Before adding a generic, state the intended relationship: the result retains the input type, a callback determines the result, or a key determines a field type. Without a useful relationship, an ordinary parameter can be clearer.

Constrain the capability the body needs

A generic function reading value.id needs a promise about id. T extends { id: string } provides that capability while allowing T to contain other fields. T extends object is insufficient: not every object has id. A T = { id: string } default also does not replace a constraint; other type arguments remain possible.

The constraint should reflect actual use. If the body only reads id and returns the object, it need not demand Customer-specific fields. This allows reuse without weakening the return. In exchange, the body can rely only on declared capabilities, even if the first observed call contains many more fields.

The limit of constructing an unknown T

A repository promising T cannot resolve absence by creating only { id }. That object meets the minimum constraint, but a consumer may have chosen Customer with required email or a subtype with a more specific condition. Applying as T hides the difference without supplying data.

Legitimate design choices include returning T | undefined and requiring callers to handle absence, returning a different minimum contract, or accepting a creation function that can produce T. Choose according to component responsibility. A supplied factory does not give the repository domain knowledge; it explicitly transfers construction to whoever has that knowledge.

Known keys and selected results

For a record with id and attempts, keyof produces the union of declared property names. A second parameter K extends keyof T relates the key to the object. T[K] retains the selected property type: a literal attempts key can produce number instead of a broad union of every value.

If the key is already a union, the read result can also be a union. This does not automatically create a discriminated key/value pair with correlated members. Also, a string index signature describes an open set of keys; it is not equivalent to a finite public list. Define the allowed list at the application boundary, even if the internal object later gains fields.

Types derived from known values

When a local array already defines its element shape, (typeof entries)[number] retrieves that type without rewriting it. Here typeof is used in type space; it is not the runtime operator returning the string "object". The number index selects array elements, and another index can select a property of those elements.

This derivation reduces duplication but neither changes data nor resolves an external boundary. An array from an unvalidated source does not become validated by querying its type. Also distinguish a value name from an alias: entries[number] is not how to retrieve the type when entries was declared only as a variable.

Inference and defaults have limits

A Packet<T = string> alias allows Packet to mean a textual payload; Packet<number> chooses another contract. The default is a type convenience. It does not make payload optional, convert numbers to text, or populate an empty object. In functions, argument inference can supply a candidate before falling back to a default.

The same caution applies to decode<Account>(text). If the body only parses JSON, choosing Account does not inspect properties. An honest boundary returns unknown until validation occurs, or accepts a function performing that validation and producing the result. The generic parameter retains a guarantee the implementation must establish.

Guided reading of a public key

In the example, row already satisfies Job. The uncertain input is field: string. The condition rejects anything other than id or attempts; after that early return, field has the finite union accepted by readField. The list remains explicit, so adding an internal field does not automatically expose it through this screen.

Trace three paths without execution: id, attempts and unknown text. Identify the result shape and who handles ok: false. The generic helper only reads the selected field; it does not validate the record, copy the object or convert output to text. If presentation always requires string, that conversion belongs to an additional visible decision.

// TypeScript 7.0; strict: true. Input is already validated.
function readField<T, K extends keyof T>(row: T, key: K): T[K] {
  return row[key];
}

type Job = { id: string; attempts: number };
type DisplayResult =
  | { ok: true; value: string | number }
  | { ok: false; reason: "unsupported-field" };

function displayField(row: Job, field: string): DisplayResult {
  if (field !== "id" && field !== "attempts") {
    return { ok: false, reason: "unsupported-field" };
  }
  return { ok: true, value: readField(row, field) };
}
IN PRACTICE

Read the rejection branch and identify field’s type afterward. Narrowing admits only the two public keys. In the accepted branch, value may be text or a number; the result does not retain which key was used.

Common pitfalls

Confusing a minimum constraint with a constructor for T; using any to hide the relationship; accepting an external key through an assertion.

Related topics: Functions and callable contracts · Objects and structural compatibility · Mapped types and conditional models

Take this idea with you

Each type parameter should retain a useful relationship. The body remains responsible for producing valid values and validating actual boundaries.

Create account

Reference: TypeScript: working with generic type variables · TypeScript 7.0; explicit strict and host configuration