Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Companion objects

For the limits of standalone functions and classes, see TypeScript problems Valof addresses.

Valof collects the constructor and functions under a value with the same name as the type:

type User = Val<"User", { id: string; name: string; nickname?: string }>;

const User = Val.sealer<User>().impl({
  displayName(user) {
    return user.nickname ?? user.name;
  },
  // A member calling another member annotates its return type, to avoid an implicit `any`.
  formatLabel(user, separator: string): string {
    return user.id + separator + User.displayName(user);
  },
});

Val.sealer<User>() creates the constructor. .impl({ ... }) adds functions under the User namespace, where they are the companion’s members, in one call that ends the chain. Every member takes its Val first, so Valof infers that parameter as User. You only annotate the parameters that follow it, and the return type where a member references User itself.

The result remains callable and exposes the members:

const user = User({ id: "a", name: "bob" });

User.displayName(user);
User.formatLabel(user, ": ");

Vals are plain data, so member functions can’t be chained like class instances. Combine Valof with any pipe library you like to avoid nesting or to reduce temporal variables.

Patch object values

Without Valof, changing a deeply readonly shop means rebuilding every object on the path:

const changed = {
  ...shop,
  owner: {
    ...shop.owner,
    contact: {
      ...shop.owner.contact,
      email: "e@example.com",
    },
  },
};

An object-shaped Val gets patch. The same update lists only what changes:

const changed = Shop.patch(shop, {
  owner: { contact: { email: "e@example.com" } },
});

A patch expresses three operations:

  • Omit a key to leave it unchanged.
  • Pass { k: undefined } to delete it.
  • Pass { k: value } to set it.

With exactOptionalPropertyTypes, { k: undefined } is accepted only for optional keys. Without it, TypeScript also accepts it for required keys, and patch deletes them at runtime.

A patch reaches through nested plain objects, but replaces a nested Val, an array or a primitive whole:

Shop.patch(shop, { city: City.patch(shop.city, { name: "Osaka" }) });

Derive a nested Val with its own patch, so its own seal sees the change.

patch copies only the path to what changed. Untouched branches keep their reference identity:

changed.city === shop.city; // true
Shop.patch(shop, {}) === shop; // true

Reference comparisons, such as React dependency arrays, can then skip work when their value did not change. Primitive and array Vals have no patch, because they have nothing to merge.