Typed Registries

The Course
•
5 min read

A registry is an object whose keys identify operations. A mapped contract can connect every key to its particular input type: Formatters<T> requires one formatter for each property in T, with that property’s own value type.

Using satisfies on an explicit object checks that coverage and contextually types the callbacks. The title callback receives string while pages receives number. A broad (value: string | number) => string everywhere would lose that useful relationship.

Types describe coverage, but runtime code still supplies every implementation. This course deliberately builds small explicit registries rather than casting the output of Object.keys into a generic mapped type. Object.keys returns runtime strings and does not prove every required function was constructed.

When an event type is added, a complete registry should fail checking until its implementation exists. That maintenance signal is valuable only if you do not silence it with a broad assertion. The final gateway uses the same idea for its channels.

Predict, check, and run

Write the output before running. Use Check types first, then Check & run. Types disappear at runtime: explain both what the compiler checks and what the code actually does. Each editor is an independent ES module; await asynchronous work explicitly.

Predict, then runTypeScript
type Formatters<T> = { [K in keyof T]: (value: T[K]) => string };

type Book = {
  title: string;

  pages: number;
};

const format = {
  title: text => text.trim(),
  pages: count => `${count} pages`
} satisfies Formatters<Book>;

console.log(format.title(" Types "));
console.log(format.pages(120));

Enable JavaScript to edit and type-check this example.

Check your prediction
Types
120 pages

Read the compiler error

Predict the rejected relationship before checking. Read the real diagnostic and its source location; fix the contract or the value instead of hiding it with an assertion.

Find and fix the type errorTypeScript
type Formatters<T> = { [K in keyof T]: (value: T[K]) => string };

const format = { pages: (count: string) => count } satisfies Formatters<{
  pages: number;
}>;

console.log(format);

Enable JavaScript to edit and type-check this example.

Check your diagnosis

The pages callback must accept number. A registry is not safe merely because its keys are present.

Coverage and payload compatibility are separate checks

A mapped registry has two responsibilities: every required key exists, and each implementation accepts the payload associated with its key. A broad function union can lose the second relationship even while all names appear to be present.

Construct a small literal satisfying the derived contract and inspect the callback parameter types. Context lets implementations remain concise without sacrificing their specific input shapes. The runtime object still has to be written or generated by real code; a mapped alias cannot manufacture it.

Coverage and payload compatibility are separate checks: traceTypeScript
type Fields = {
  title: string;

  pages: number;
};

type Formatters<T> = { [K in keyof T]: (value: T[K]) => string };

const format = {
  title: value => value.trim(),
  pages: value => `${value} pages`
} satisfies Formatters<Fields>;

console.log(format.title(" Types "));
console.log(format.pages(0));

Enable JavaScript to edit and type-check this example.

Check your prediction
Types
0 pages

Each callback receives a different type. The pages formatter can use zero as meaningful data without a truthiness fallback.

Coverage and payload compatibility are separate checks: contrastTypeScript
type Events = {
  added: { title: string };

  removed: { id: string };
};

type Formatters<E> = { [K in keyof E]: (value: E[K]) => string };

const format = {
  added: value => value.title,
  removed: value => value.id
} satisfies Formatters<Events>;

console.log(Object.keys(format).join(","));
console.log(format.removed({ id: "b1" }));

Enable JavaScript to edit and type-check this example.

Check your prediction
added,removed
b1

The explicit registry implements both derived requirements. Adding a key to Events will demand another implementation; it will not add one to this runtime object automatically.

Try the rule on new inputs

Remove one key and then change one callback input independently. Read the different diagnostics. Keep a positive call for each key so an over-restrictive contract cannot pass a negative-only test suite.

Build it yourself

Exercise

Typed Registries: practise

Create Formatters for {enabled:boolean;minutes:number}. Format false as Off and 25 as 25 min. Use satisfies, with no explicit callback parameter annotations and no any.

Your workspaceTypeScript
// Start from the stated contract. Add a valid call and an edge case.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Context comes from the mapped contract at the object boundary.

Hint 2: a first step

Keep a formatter under each original key and branch on the boolean input.

Show one solution
One possible solutionTypeScript
type Formatters<T> = { [K in keyof T]: (value: T[K]) => string };

const format = {
  enabled: value => (value ? "On" : "Off"),
  minutes: value => `${value} min`
} satisfies Formatters<{
  enabled: boolean;

  minutes: number;
}>;

console.log(format.enabled(false));
console.log(format.minutes(25));

Enable JavaScript to edit and type-check this example.

Off
25 min

Trace the implementation against the task before comparing styles. A passing example supports that case; use the changes below to challenge the wider contract.

Change it and test it

Remove minutes and inspect the missing-property error. Then call the enabled formatter with text and inspect the argument error. These protect different parts of the contract.

Was this article helpful?

Your response is saved on this device.