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

Mapped types and conditional models

Derive contracts with mapped and conditional types, control distribution and distinguish a calculated interface from its implementation.

Transform a model’s contract

A dashboard may need one boolean option per known action. Instead of manually repeating every key, a mapped type traverses keyof T and associates each key with the chosen type. The relationship becomes explicit: a model change can also appear in the derived contract. This helps reveal incomplete maps during maintenance.

Mapping operates on types. It does not traverse a runtime object, populate defaults or create missing fields. If a form needs initially false flags, someone must construct that object. Review both parts: how the contract is derived and how implementation supplies the promised values.

Modifiers and depth

The ? and readonly modifiers control optionality and writes through a type. A - prefix removes them in a mapping: -? can require a formerly optional field, while -readonly permits writes through the resulting view. These operations neither supply required data nor thaw an object frozen at runtime.

A shallow readonly mapping retains T[K] as it is. If a property contains an object with mutable fields, those fields remain mutable through that type. Before promising immutable configuration, decide whether the requirement prevents property replacement, nested writes or shared references. These are different contracts requiring explicit choices.

Rename or exclude keys

A mapped type’s as clause calculates the resulting key. It can produce new names or never to exclude a key. Excluding a key differs from retaining a property whose value is never: the former removes it from the interface; the latter leaves a requirement no ordinary value can satisfy.

A type transforming name into getName(): string does not implement the method. An assertion applied to the original object can make a consumer trust a nonexistent method. Likewise, excluding secret from a type does not delete it from an object sent as JSON. Implement and review data transformation separately from contract transformation.

Select and extract types

A conditional type expresses an assignability decision. In T extends { payload: unknown } ? T["payload"] : never, the true branch already knows the required property exists and can query its type. unknown in the condition does not require losing the concrete payload type. The false branch can represent that this extraction produces no valid member.

infer names a part found in the compared shape. A readonly array pattern can extract element types from arrays or tuples without consuming values. Using readonly in the pattern matters when the API should also accept readonly tuples. This remains a type operation, with no inspection of arrays received at runtime.

Distribution or a whole-union decision

When the condition’s left side is a naked T parameter, a union can be evaluated member by member. TextMembers<string | number> retains string and discards number through never. This is useful for filtering a union. It does not mean the program filtered a list of received values.

The [T] extends [string] form prevents that distribution. With string | number, the entire union is not assignable to string and the result is false. Choose the operation according to the contract question: which members are textual, or are all members textual? Choosing incorrectly can admit a model the consumer cannot fully handle.

The empty case and unexpected results

never represents a set with no possible values. In a distributed condition applied to never, there are no members over which to produce results, so the result remains never. In a tuple-wrapped condition, [never] is assignable to [string], so that form can select true. This is no runtime conversion of never into a string.

When an alias produces an unexpected result, mentally substitute small cases: an accepted member, a rejected member, their union and never. Observe where the naked parameter appears and where a tuple appears. This reading helps diagnose the contract without adding assertions that conceal the problem at subsequent calls.

A complete local map with satisfies

In the example, Descriptions requires one function for each State member. The object literal supplies those functions, and satisfies checks the relationship without annotating the whole object with a broader value type. Removing blocked no longer satisfies the contract. Adding a state requires reviewing the map instead of waiting for the first access with no function.

satisfies was introduced in TypeScript 4.9 and is used here under the 7.0 baseline. It is neither a general guarantee against extra properties in every structural object nor an external-request validator. The practical question uses a local literal and a finite union. External data still needs validation before calling describe; the example starts with an already valid State.

// TypeScript 7.0; strict: true. No external data in this example.
type State = "queued" | "running" | "blocked";
type Descriptions = { [K in State]: () => string };

const descriptions = {
  queued: () => "Waiting for capacity",
  running: () => "Work in progress",
  blocked: () => "Needs a decision"
} satisfies Descriptions;

type TextMembers<T> = T extends string ? T : never;
type EveryMemberIsText<T> = [T] extends [string] ? true : false;
type Allowed = TextMembers<string | number>;
type AllAllowed = EveryMemberIsText<string | number>;

function describe(state: State): string {
  return descriptions[state]();
}
IN PRACTICE

Identify the three functions existing in the descriptions value. Then compare Allowed, which filters members, with AllAllowed, which checks the complete union. The aliases perform no data filtering.

Common pitfalls

Expecting a mapped type to create methods or remove fields; confusing distribution with a whole-union check; treating satisfies as an external-data validator.

Related topics: Unions, narrowing and state models · Generics and key relationships · External data and error boundaries

Take this idea with you

Deriving types avoids duplicated contracts. Matching those contracts to values still requires implementation and appropriate checks.

Create account

Reference: TypeScript: mapping modifiers · TypeScript 7.0; explicit strict and host configuration