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

Introduction

Valof

The Valof Book

v0.8.2

Write robust domain logic in TypeScript
just by following the conventions.

npm

The Valof Book is both a guide and a reference. Read it in order to learn the conventions, or use the sidebar to find a specific topic.

Important

Valof is pre-1.0. A minor release can change the API.

Why Valof?

Valof is an opinionated value-object helper for TypeScript that enforces the conventions through types and linting.

  • Nominal-ish typing with a phantom brand: TypeScript distinguishes one Val type from another, with nothing to pay at runtime.

  • Values as plain data: Vals stay objects, arrays and primitives. They serialize without adapters and fit directly into React and other framework state. No classes, no prototypes.

  • Immutability: Vals are immutable. Their types are deeply readonly, and their constructors copy their inputs, so an original reference cannot mutate them.

  • No as casts in your code: Valof owns the cast required to construct a branded value.

  • Companion object: Keep a type’s constructor and functions together without a class. The first Val parameter is inferred.

  • “Parse, don’t validate”: Every creation and derivation goes through one seal. Use any validation library and any Result type.

  • Copy only what changes: patch copies only the paths it changes. Untouched branches keep their reference identity.

  • Rust-like abstractions (experimental): Enum is a closed set of variants with exhaustive match. Trait shares behavior across Vals. dyn adds dynamic dispatch without classes, holding different types together.

  • Built-in linter: Catch convention violations through ESLint, Oxlint or the standalone command.

  • Lightweight: Starts from 852 B gzipped, with no runtime dependencies.

For background on individual features, see TypeScript problems Valof addresses.

TypeScript problems Valof addresses

This chapter explains the TypeScript problems behind individual Valof features. Use the sidebar for how to use the library.

Branding

Two values can have the same representation but different meanings:

type UserId = string;
type OrderId = string;

declare const userId: UserId;
let orderId: OrderId;

orderId = userId; // allowed: both types are string

A brand lets TypeScript distinguish them without changing their runtime representation. A branded type normally needs a constructor that contains a type assertion. This keeps assertions out of callers, but every branded type still needs the same constructor boilerplate, including an as cast. Val.sealer supplies the constructor. Declare the type and its constructor together; branding covers names, construction and the rules that keep the brand intact:

type UserId = Val<"UserId", string>;
const UserId = Val.sealer<UserId>();

const id = UserId("u1");

Immutability

Readonly<T> is shallow. TypeScript has no built-in DeepReadonly, and a mutable original reference can still change a readonly view. A recursive DeepReadonly type protects nested fields, but updating one immutably still means rebuilding each object on the path.

Valof makes these conventions the default. Val applies DeepReadonly, its constructor copies the input to remove mutable aliases, patch rebuilds only the paths it changes, and .nocopy is the explicit escape hatch for a payload that already has no mutable aliases; immutability shows the copy and .nocopy contracts:

type Profile = Val<"Profile", { name: string; address: { city: string } }>;
const Profile = Val.sealer<Profile>();

const profile = Profile({ name: "alice", address: { city: "Osaka" } });
const changed = Profile.patch(profile, { address: { city: "Tokyo" } });

Companion objects

A TypeScript type does not create a value namespace. Its functions are usually standalone:

type User = { id: string; name: string; nickname?: string };

function getUserDisplayName(user: User) {
  return user.nickname ?? user.name;
}

function formatUserLabel(user: User, separator: string) {
  return user.id + separator + getUserDisplayName(user);
}

The type name appears in each function name to keep related functions recognizable. Each function also repeats the type annotation for its first parameter.

A class provides a namespace for its functions, but also turns each value into a class instance. Keeping its instances immutable requires readonly on every field and nested property. Its JSON shape depends on property enumerability unless you write and maintain a toJSON mapping.

A companion object collects the constructor and functions under the type’s name while values remain plain data. Val.sealer<V>().impl({ ... }) creates it. The result stays callable and exposes the registered members; the chapter also explains patch:

type User = Val<"User", { name: string }>;
const User = Val.sealer<User>().impl({ greeting: (user) => `Hi, ${user.name}` });

User.greeting(User({ name: "alice" }));

Parse, don’t validate

Valof makes parsing a convention. A companion has one seal, and seal, create and patch pass their payloads through it. A custom seal returns a validated, normalized Val or its failure, so the result type records that parsing succeeded.

Start with Val.companion<V>() and register the parser with .implSeal. The resulting .seal returns the value or the parser’s failure; custom constructors explains schemas, normalization and generated fields:

type Age = Val<"Age", number>;
const Age = Val.companion<Age>().implSeal((value, seal) =>
  value >= 0 ? seal(value) : new Error("age must not be negative"),
);

const age = Age.seal(30); // Age | Error

Enums

An enum keeps a closed set of variants in one declaration and requires a handler for every variant.

A hand-written union scatters the set

TypeScript already models a closed set as a discriminated union, and a base type for the fields every variant holds:

type Base = { id: string };
type Circle = Base & { _tag: "Circle"; r: number };
type Square = Base & { _tag: "Square"; side: number };
type Shape = Circle | Square;

Adding Triangle is three edits: a new type, a new arm on the union, and & Base again. Forget the last one and the declaration still compiles. TypeScript reports it at the first shape.id, not at the variant missing & Base.

A new variant breaks a switch silently

A switch on the tag narrows, and a missing case is an error where the return type is annotated and every arm returns. A switch that runs side effects has neither, so adding Triangle compiles:

type Triangle = { _tag: "Triangle"; base: number; height: number };
type Shape = Circle | Square | Triangle;

function draw(shape: Shape) {
  switch (shape._tag) {
    case "Circle":
      drawCircle(shape);
      break;
    case "Square":
      drawSquare(shape);
      break;
  }
}

A triangle draws nothing, and no type says so. You write the check yourself: assertNever(shape) under a default, and a break closing every case. strict reports neither a missing case nor a missing break. noFallthroughCasesInSwitch catches the second, and the lint rule typescript/switch-exhaustiveness-check catches the first, once you enable both.

Declare variants in Enum<...> and create their companion with Enum.sealer<E>(). companion.match(value, handlers) requires a handler for every variant; enums covers shared fields, custom seals and tags:

type Shape = Enum<"Shape", { Circle: { r: number }; Square: { side: number } }>;
const Shape = Enum.sealer<Shape>();

const area = Shape.match(Shape.Circle({ r: 2 }), {
  Circle: (circle) => Math.PI * circle.r * circle.r,
  Square: (square) => square.side * square.side,
});

Traits

A trait declares the fields and functions that several Vals share.

A function does not declare a contract

A function can accept the fields it reads, and every type holding them fits:

type User = { id: string; name: string };
type Admin = { name: string; level: number };

function greet(greetable: { name: string }): string {
  return `Hi, ${greetable.name}`;
}

The parameter is the contract, written again in every function that wants it. Rename a field and the type remains valid. The function remains valid. Only a call fails.

The error lands away from the type that broke it:

greet(user); // type error: User has no `name` any more

A companion collects one Val’s functions, but does not declare behavior shared by other Vals. A trait declares shared fields and functions once. Each implementing Val points to that declaration, so a renamed field fails at the Val that broke the contract.

const admin = Admin({ name: "root", level: 9 });

User.greet(admin); // type error: greet belongs to User

Admin holds the name that greet reads, and User.greet rejects it all the same. Collecting behavior and sharing it are still two different things.

Declare a Trait, add it to a Val’s third type argument, then register its members with .implTrait; traits covers defaults, enums and dyn:

type Describable = Trait<"Describable", { name: string }, { describe: (self: Self) => string }>;
const Describable = Trait.companion<Describable>();

type User = Val<"User", { name: string }, Describable>;
const User = Val.sealer<User>().implTrait(Describable, { describe: (user) => user.name });

Installation

npm install valof

Valof requires TypeScript 5.9 or later. The recommended compiler options are:

  • strict (the default from TypeScript 6 on)
  • exactOptionalPropertyTypes

Without exactOptionalPropertyTypes, { a?: string } also accepts undefined, even though JSON serialization drops that key.

Branding

For the problem that branding solves, see TypeScript problems Valof addresses.

Define branded types

Val takes the brand and the payload type:

import { Val } from "valof";

type UserId = Val<"UserId", string>;
type OrderId = Val<"OrderId", string>;

The brand is phantom. A UserId is still a string at runtime.

Name the brand after the type it brands: type UserId = Val<"UserId", string>. The brand-mismatch lint rule reports when the names do not match.

Construct values

Val.sealer supplies the constructor:

const UserId = Val.sealer<UserId>();
const OrderId = Val.sealer<OrderId>();

const userId = UserId("u_1");
let orderId: OrderId;

orderId = userId; // type error: UserId is not an OrderId
orderId = "o_1"; // type error: a plain string is not an OrderId

type User = Val<"User", { name: string }>;
const User = Val.sealer<User>();
const user = User({ name: "alice" });

// @ts-expect-error spread drops the brand
const changed: User = { ...user, name: "bob" };
const resealed = User({ ...user, name: "bob" });

Name the constructor after its type: const UserId = Val.sealer<UserId>(). TypeScript lets the type and value share a name. The companion-mismatch lint rule reports when they do not match.

Immutability

Readonly<T> only protects the top-level properties. It also cannot prevent a value from changing through the original reference:

type Profile = Readonly<{
  name: string;
  address: { city: string };
}>;

const raw = { name: "alice", address: { city: "Osaka" } };
const profile: Profile = raw;

profile.name = "mallory"; // type error: the top-level property is readonly
profile.address.city = "Tokyo"; // allowed: Readonly is shallow
raw.name = "mallory"; // allowed: raw is still writable

A recursive readonly type prevents the nested write, but the original reference remains writable. Keeping a value unchanged requires both deep readonly types and a copy at construction.

Vals are immutable

Vals are immutable. Their types are deeply readonly, and their constructors deep-copy their inputs:

type Profile = Val<"Profile", { name: string; address: { city: string } }>;
const Profile = Val.sealer<Profile>();

const raw = { name: "alice", address: { city: "Osaka" } };
const profile = Profile(raw);

profile.address.city = "Tokyo"; // type error: Vals are deeply readonly
raw.address.city = "Tokyo";
profile.address.city; // "Osaka"

Write the payload without readonly. Val makes it deeply readonly:

type Post = Val<"Post", { tags: string[] }>;

declare const post: Post;
post.tags.push("typescript"); // type error: tags is readonly

readonly exists only in the type system. In development, Valof also freezes values, so a write that uses a cast to bypass the type throws where it happens. Production builds skip the freeze.

Only primitives, arrays and plain objects can live inside a Val. See Allowed types.

Avoiding a copy

Construction copies by default. Do not start with .nocopy; reserve it for a copy bottleneck that profiling has confirmed.

Warning

.nocopy is an explicit escape hatch. You must ensure the value and everything inside it is stable plain data with no mutable aliases. If that is wrong, another reference can change a Val in production. Prefer the default copy whenever there is doubt.

Only when you can make that guarantee, use .nocopy to make the payload the value without copying:

import { Val } from "valof";

type Profile = Val<"Profile", { name: string; address: { city: string } }>;
const Profile = Val.sealer<Profile>();

const seed = { name: "alice", address: { city: "Osaka" } } as const;
const profile = Profile.nocopy(seed);

The common trap is a mutable alias behind a readonly view:


const mutable = { name: "alice", address: { city: "Osaka" } };
const seed: SeedOf<Profile> = mutable; // TypeScript permits this readonly view.
const profile = Profile.nocopy(seed);

// In production, this also changes `profile.address.city`.
// Development freezing normally makes this assignment throw instead.
mutable.address.city = "Tokyo";

readonly is only a guardrail. Mutable views, casts, accessors and proxies can still break this contract. Default sealers require deeply readonly input; Val.of.nocopy<V> and custom seals leave the responsibility to their callers and implementations. Development validates and freezes the payload graph; production trusts the contract. patch still copies.

Allowed types

Only three things can be a payload:

Primitivesstring / number / boolean / bigint; null below the top level
Arraysallowed[], or a tuple: [allowed, allowed]
Objects{ k: allowed }, or Record<string, allowed>

A Val is itself one of these, so Vals nest. A tuple keeps its positions and its length. One with a rest element ([string, ...number[]]) reads as an array instead, since a fixed length is what distinguishes the two.

Write the payload plain

Val makes the payload deeply readonly on its own, so readonly in the definition changes nothing about the value. Writing it makes Val.unwrap return a readonly payload, which is what unwrap exists to avoid.

type Post = Val<"Post", { tags: string[] }>; // not `readonly string[]`

post.tags; // readonly string[] all the same
Val.unwrap(post).tags.sort(); // ✓ returns a mutable `string[]`

Classes and functions

Neither a class instance nor a function can be a payload. Date, Temporal, Map and Set are all classes; see Dates and Map / Set instead. TypeScript rejects them, on the first use of the Val rather than on the type line.

A class of plain fields is the one TypeScript cannot distinguish from an object. Sealing one throws in development. A production build skips that check and copies the own enumerable keys, so a Date is copied as {}, and an instance keeps its fields but loses its prototype.

A type that references itself

A tree holds trees. Write the self-reference as Rec<Tree>.

type Tree = Val<"Tree", { value: number; children: Rec<Tree>[] }>;
const Tree = Val.sealer<Tree>();

const leaf = Tree({ value: 1, children: [] });
const root = Tree({ value: 2, children: [leaf] });

root.children[0]; // Tree

Rec is erased. The value holds a Tree, the constructor takes a Tree, and patch and the companion’s members work as they do for any other type. It exists for the declaration alone.

Without it the declaration compiles and the first use of the type fails. Resolving Tree would need Tree, and a Rec is a reference to an interface, which TypeScript resolves later.

type Tree = Val<"Tree", { value: number; children: Tree[] }>;

const Tree = Val.sealer<Tree>();

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.

Custom constructors

Validate in the seal

Val.sealer accepts every payload allowed by the type. When construction has rules of its own, start with Val.companion and add a custom seal with .implSeal.

Write the checks directly in the seal:

type Age = Val<"Age", number>;

const Age = Val.companion<Age>().implSeal((value, seal): Result<Age> =>
  value >= 0 && Number.isInteger(value)
    ? ok(seal(value))
    : err("age must be a non-negative integer"),
);

Val.companion starts without a constructor. .implSeal receives the input and the default seal, then adds your constructor to the companion as seal. Return through the default seal to brand and copy the payload. The companion does not become callable:

Age(30); // type error: this expression is not callable
Age.seal(30); // Result<Age>

Age.seal.nocopy(value) runs exactly the same validation and normalization, but does not copy the payload passed to the default seal inside the custom seal. Likewise, create.nocopy(...args) reuses the registered create function and seal. Both carry the caller contract described in Immutability.

Valof provides no Result type. neverthrow, better-result and your own type all work. Valof propagates the seal’s return type without inspecting it.

A schema library can parse a wider input before calling the default seal:

const schema = z.object({ id: z.uuid(), name: z.string().min(1), email: z.email().toLowerCase() });

const User = Val.companion<User>().implSeal((input: object, seal): Result<User> => {
  const result = schema.safeParse(input);
  return result.success ? ok(seal(result.data)) : err(z.prettifyError(result.error));
});

The parameter takes object or Record<string, unknown>, not unknown. A seal takes the payload, not a wire format.

The schema runs on every derivation, not just the first parse. Reject unknown keys yourself. A patch is merged as given, so an undeclared key survives unless the seal removes it.

Normalize in the seal

Normalize in the seal so equivalent inputs have the same canonical form:

type Email = Val<"Email", string>;

const Email = Val.companion<Email>().implSeal((value, seal) => seal(value.trim().toLowerCase()));

Canonical payloads make structural equals match what equality means in your domain.

Generate fields with create

create builds a payload, then passes it to the seal:

const User = Val.companion<User>()
  .implCreate((fields: Fields) => ({ id: crypto.randomUUID(), ...fields }))
  .implSeal((user): Result<User> => check(user));

User.create(fields); // Result<User>

A seal must be idempotent. patch on an object-shaped Val and any registered create pass their payloads through your seal. Sealing a value’s own payload must return that value. Generate an id or timestamp in create, not in seal.

Keep generated fields fixed

create can generate an id, a createdAt or a version counter. .fixed excludes those fields from patch:

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

const User = Val.companion<User>()
  .implCreate((fields: Omit<SeedOf<User>, "id">) => ({
    id: crypto.randomUUID(),
    ...fields,
  }))
  .implSeal((user, seal) => seal(normalize(user)))
  .fixed<"id">();

User.patch(user, { name: "sue" }); // OK
User.patch(user, { id: "forged" }); // type error

The keys are a type argument, so they do not exist at runtime. This constrains patch, not the value. Val.of<User>({ id: "forged", … }) still builds one, and so does a patch typed any. If an id must be unforgeable, it belongs outside the value.

Parse, don’t validate

A validator checks its input but returns no more precise value:

const valid = isValidAge(input); // boolean; input is still a number

The type records nothing that the validator learned. Each consumer must trust that the check ran or repeat it. This spreads validation through processing code, an anti-pattern called shotgun parsing.

A parser instead turns less precise input into more precise output, or returns a failure:

const result = Age.seal(input); // Result<Age>

Age.seal is that parser. Once a value has been successfully sealed as an Age, its type guarantees that it is validated and normalized wherever it is passed, so downstream code does not need to repeat either step. The seals in this chapter put “parse, don’t validate” into practice. They return a validated, canonical Val instead of returning facts about the input. Parsing includes validation, but preserves its result in a more precise type. In Takuto Wada’s words, parse, don’t (just) validate: the Age type can now represent only valid ages. create and patch reuse the same parser.

Enums

Warning

Enums are experimental. They ship from valof/experimental so that an import says so, and the design is still changing.

An enum is a closed set of variants. A Val is one shape; an enum is a choice between several, and because the set is closed, handling every case can be checked.

For the limits of a hand-written discriminated union, see TypeScript problems Valof addresses.

Declare the variants

import { equals } from "valof";
import { Enum } from "valof/experimental";

type Shape = Enum<"Shape", { Circle: { r: number }; Square: { side: number } }>;

const Shape = Enum.sealer<Shape>();

const circle = Shape.Circle({ r: 2 }); // { r: 2, _tag: "Circle" }

Shape.Circle.patch(circle, { r: 5 }); // { r: 5, _tag: "Circle" }, the tag stays
equals(circle, Shape.Circle({ r: 2 })); // true

The record is the whole declaration. The union is derived from it, and so is each variant’s brand: Shape.Circle is branded "Shape.Circle". Adding a variant is one line, in one place.

Call a variant through the companion. const { Circle } = Shape works, and a reader of Circle({ r: 2 }) then has to find which enum declared it. const Round = Shape.Circle gives it another name, which companion-mismatch reports.

The constructor writes the tag. It does not take one, and it deep-copies its payload like any other constructor. The enum itself takes a payload that already carries the tag, typed SealedPayload<Shape>. It reads the tag, selects that variant’s companion, and passes the payload to its seal.

A variant is a Val. It patches, it compares, and it nests. A patch cannot reach the tag, so it cannot switch variants.

type Style = Enum<"Style", { Solid: { width: number }; Dashed: { gap: number } }>;
type Card = Enum<"Card", { Plain: { w: number }; Framed: { w: number; style: Style } }>;

A nested variant is a patch boundary like any nested Val. Replace it with one the constructor built, rather than merging into it.

Define two variants at least. One variant is a Val, and TypeScript loses the alias for a union of one, which breaks the declarations of a package that exports the companion.

Match on the tag

const area = Shape.match(shape, {
  Circle: (c) => Math.PI * c.r * c.r,
  Square: (s) => s.side * s.side,
});

Each handler takes its own variant, narrowed, with no annotation. Every variant needs a handler, so adding one to the declaration fails here rather than falling through at run time. No break, no assertNever, and no lint rule to enable. The return type is the handlers’ union.

match sits on the companion because the tag’s name is customizable, and a free function would have to hard-code it.

Use match to split on the tag. For a condition inside a variant, a pattern matching library such as ts-pattern fits. _tag is real data, so .with({ _tag: "Circle" }, …) already works.

Common shape for every variant

The third argument is the shape: the fields every variant holds. It is the same argument a trait takes.

type Task = Enum<"Task", { Todo: { text: string }; Done: { at: number } }, { id: string }>;
const Task = Enum.sealer<Task>();

const todo = Task.Todo({ id: "t1", text: "write the docs" });
console.log(todo.id); // t1

Every constructor requires the shape, the union reads it without a match, and VariantOf carries it.

The same argument declares the traits the enum implements.

Members

.impl collects the members that take the union, and .implVariant builds one variant, named in the first argument. .implVariant takes a callback, passed that variant’s steps.

type Shape = Enum<"Shape", { Circle: { r: number }; Square: { side: number } }>;

const Shape = Enum.sealer<Shape>()
  .implVariant("Circle", (sealer) => sealer.impl({ diameter: (c) => c.r * 2 }))
  .impl({
    // A member calling `match`, a variant, or a sibling annotates its return type, to avoid an
    // implicit `any`. A member written inside `.implVariant` reaches the enum's companion the
    // same way.
    area: (s): number =>
      Shape.match(s, {
        Circle: (c) => Math.PI * c.r * c.r,
        Square: (q) => q.side * q.side,
      }),
  });

Shape.Circle.diameter(Shape.Circle({ r: 2 })); // 4

A variant you write nothing for keeps the default companion, and a variant already built cannot be named again. The steps are already that variant’s companion, so a callback with nothing to collect returns the argument: .implVariant("Circle", (sealer) => sealer). What a step returns is closed. That variant takes no further step.

No step chooses between a sealer and a companion. Enum.sealer makes every variant callable, Enum.companion builds every one with .create, so the entry point you choose for the enum applies to every variant.

.impl takes one call, which closes every step, so nothing can add a second seal to a companion you export. Call it with nothing where there is no member to collect: Enum.sealer<Shape>().implVariant(…).impl().

Check the payload

Enum.sealer accepts every payload the type allows. When construction has rules of its own, start from Enum.companion, the same as a Val. Every variant then builds with .create, and the enum gains a seal of its own.

type Shape = Enum<"Shape", { Circle: { r: number }; Square: { side: number } }, { id: string }>;

const Shape = Enum.companion<Shape>()
  .implSeal((payload, seal) => (payload.id ? seal(payload) : new Error("id must not be empty")))
  .implVariant("Circle", (companion) =>
    companion.implSeal((payload, seal) =>
      payload.r > 0 ? seal(payload) : new Error("r must be positive"),
    ),
  )
  .impl();

Shape.Circle.create({ id: "s1", r: 2 }); // VariantOf<Shape, "Circle"> | Error
Shape.Square.create({ id: "s2", side: 1 }); // VariantOf<Shape, "Square"> | Error

The enum’s seal checks what every variant holds; a variant’s own seal checks its own payload. A payload runs the variant’s seal, then the enum’s, then the default seal that brands and copies it, so a variant with no seal of its own is still checked by the enum’s. Compose them yourself where a check depends on the other’s result. Inside a variant’s seal, seal(payload) is the enum’s seal, so its result is the enum’s return, the error included.

Write .implSeal before the first .implVariant, which the type enforces. A variant’s default seal is the enum’s, read from the chain as it stands.

Whatever a seal returns propagates, as it does for a Val. The union in it narrows to the variant the payload named. patch derives through the same seal, so no derivation skips it.

On a companion, Shape.seal takes that tagged payload. Its return is the variants’ seals as a union.

Custom tag key

The tag is real data, not a phantom. It crosses the wire, so you can customize the name when an API already has one:

type Event = Enum<"Event", { Click: { x: number }; Key: { code: string } }, Tag<"kind">>;
const Event = Enum.sealer<Event>("kind");

Event.Click({ x: 1 }); // { x: 1, kind: "Click" }

Tag goes in the third argument, beside the shared fields, because the tag is a field every variant holds. It is a marker with no key of its own, so every name is still free for a variant. The companion takes the name again because the proxy writes it at run time, and the type argument is not readable from a value. Forget it, misspell it, or pass one where the default applies, and the type says so.

Use a variant’s type

VariantOf is the type of a single variant, and it is what errors and hovers print. Write it where you need it.

const area = ({ r }: VariantOf<Shape, "Circle">) => r * r * Math.PI;

Traits

Warning

Traits are experimental. They ship from valof/experimental so that an import says so, and the design is still changing.

For the contract that a trait adds, see TypeScript problems Valof addresses.

Declare what Vals share

A trait declares the fields and the functions:

import { Trait, type Self } from "valof/experimental";

type Greetable = Trait<
  "Greetable",
  { name: string },
  {
    greet: (self: Self) => string;
    toWire: (self: Self, sep: string) => string;
  }
>;

The second argument is the shape: the fields every implementing Val holds. It follows the same rules as a payload, so a shape no Val could ever hold is an error where it is written.

The third declares the functions. They become members of every companion that implements the trait, and they take the value first like any other member.

Self stands for the implementing Val. A member may take it, and may not return it. What returns a Self is a constructor, and a trait has no brand to seal with.

Implement it on a Val

A Val declares its traits in the third argument, and its companion implements them:

type User = Val<"User", { id: string; name: string }, Greetable>;
const User = Val.sealer<User>().implTrait<Greetable>({
  greet: (u) => `Hi, ${u.name}`,
  toWire: (u, sep) => `${u.id}${sep}${u.name}`,
});

User.greet(User({ id: "a", name: "alice" })); // "Hi, alice"

Naming the trait as the type argument asks implTrait for every member it declares.

Declaring the trait is what requires the payload to hold its fields. A User without name is a type error at the declaration, not at implTrait.

The checker stops at the declaration. Val<"User", …, Greetable> typechecks with no implTrait anywhere, so the unimplemented-trait rule in valof-lint is what reports the Val that declared a trait and never implemented it.

A trait is a contract between Vals

A plain object that happens to hold the fields is not one of them:

type Greetable = Trait<"Greetable", { name: string }, { greet: (self: Self) => string }>;

const duck: Greetable = { name: "duck" }; // type error: the brand is missing

A Val declaring the trait carries its brand, and that brand is what the trait type asks for. The fields alone do not put it there, so only a Val that declared Greetable is assignable to it.

Give a default implementation

Every Val writing its own greet repeats the same line. A trait can implement a member itself, reading the shape alone, and that becomes the default for every Val that does not replace it:

const Greetable = Trait.companion<Greetable>().impl({ greet: (g) => `Hi, ${g.name}` });

type User = Val<"User", { id: string; name: string }, Greetable>;
const User = Val.sealer<User>().implTrait(Greetable, {
  toWire: (u, sep) => `${u.id}${sep}${u.name}`,
});

type Admin = Val<"Admin", { name: string; level: number }, Greetable>;
const Admin = Val.sealer<Admin>().implTrait(Greetable, {
  toWire: (a, sep) => `admin${sep}${a.name}`,
  greet: (a) => `Sir ${a.name}`,
});

User.greet(User({ id: "a", name: "alice" })); // "Hi, alice"
Admin.greet(Admin({ name: "root", level: 9 })); // "Sir root"

A trait that implements something takes its companion as the first argument. The second is what the trait left open, plus any default the Val replaces, as Admin replaced greet.

Defaults no Val may replace

Mark one Final:

type Greetable = Trait<
  "Greetable",
  { name: string },
  {
    greet: (self: Self) => string;
    shout: Final<(self: Self) => string>;
  }
>;

const Greetable = Trait.companion<Greetable>().impl({
  greet: (g) => `Hi, ${g.name}`, // a Val may replace this one
  shout: (g) => g.name.toUpperCase(), // declared Final: no Val may
});

type User = Val<"User", { id: string; name: string }, Greetable>;
const User = Val.sealer<User>().implTrait(Greetable);

The trait implements both, so implTrait needs no second argument. A Val may still pass greet to replace it. Passing shout is an error.

Only Final members are exposed on the trait’s own type. Greetable.shout(user) typechecks and Greetable.greet does not.

A default calling a Final member references the trait and annotates its return type:

const Greetable = Trait.companion<Greetable>().impl({
  shout: (g): string => g.name.toUpperCase(),
  // A member referencing `Greetable` itself annotates its return type, to avoid an implicit `any`.
  greet: (g): string => `Hi, ${Greetable.shout(g)}`,
});

A member a Val may replace is not on Greetable, because reading it off the trait would run the default even for a Val that replaced it. Call it through that Val’s companion, which dispatches.

Implement Traits on an Enum

An enum implements a trait once, over the union. What differs per variant is a match inside the implementation:

type Describable = Trait<"Describable", { id: string }, { describe: (self: Self) => string }>;
const Describable = Trait.companion<Describable>();

type Cmd = Enum<"Cmd", { Add: { n: number }; Del: { at: number } }, { id: string } & Describable>;
const Cmd = Enum.sealer<Cmd>().implTrait(Describable, {
  describe: (c): string => Cmd.match(c, { Add: (a) => `add ${a.n}`, Del: (d) => `del ${d.at}` }),
});

Cmd.describe(Cmd.Add({ id: "c1", n: 2 })); // "add 2"

An enum declares its shared fields and its traits in one argument. A trait brings the fields it requires, so declaring them again is not needed. Variants cannot implement traits.

Hold values of different types together

dyn pairs a value with one Val’s implementation, so values of different types share an array. The concrete type is gone; the trait is what is left. This is inspired by Rust’s Box<dyn Trait>:

const party: Dyn<Greetable>[] = [
  Greetable.dyn(User, User({ id: "a", name: "alice" })),
  Greetable.dyn(Admin, Admin({ name: "root", level: 9 })),
];

party.map((p) => p.greet()); // ["Hi, alice", "Sir root"]
party.map((p) => p.name); // ["alice", "root"]: a trait field, read from the box

A box binds the receiver, so its members take the remaining arguments alone. The trait’s fields are readable on it, and a function taking Greetable accepts one. The Val’s own fields are not. p.id is a type error, because dyn drops the concrete type.

A box is a proxy over its value, not a Val. It has its own identity, and it has no patch. A payload cannot hold one.

The two arguments belong together. The companion has to match the value’s own type, so another Val’s companion is rejected.

An enum boxes the same way, through its own companion: Describable.dyn(Cmd, Cmd.Add({ id: "c1", n: 2 })).

Several traits on one Val

Intersect them in the declaration, and implement each in its own step:

type Weighed = Trait<"Weighed", { kg: number }, { heavy: (self: Self) => boolean }>;
const Weighed = Trait.companion<Weighed>().impl({ heavy: (w) => w.kg > 10 });

type Crate = Val<"Crate", { name: string; kg: number }, Greetable & Weighed>;
const Crate = Val.sealer<Crate>().implTrait(Greetable).implTrait(Weighed);

const crate = Crate({ name: "box", kg: 20 });
Greetable.dyn(Crate, crate).greet(); // "Hi, box"
Weighed.dyn(Crate, crate).heavy(); // true

Write &, not |.

Each trait boxes on its own. No two traits on one Val may declare the same member name.

Names a member may not take

A member name is rejected when a Val could not carry it:

  • a field’s name from the trait’s own shape
  • patch, seal or create, which the library defines
  • anything under __valof_ or starting with impl
  • dyn, which the trait itself uses
  • then, which would make the companion a thenable

Each is reported where the trait is declared.

A member may not take the name of a field the implementing Val holds. That includes a field another trait on the same Val requires. The colliding name comes from the Val, so this one is reported at implTrait.

Utilities

equals

equals compares two Vals of the same type structurally and deeply:

import { equals, Val } from "valof";

type User = Val<"User", { id: string; profile: { name: string } }>;
const User = Val.sealer<User>();

const a = User({ id: "a", profile: { name: "alice" } });
const b = User({ id: "a", profile: { name: "alice" } });

a === b; // false
equals(a, b); // true

The comparison:

  • compares array elements in order
  • is independent of object key order
  • ignores keys whose value is undefined ({ a: undefined } equals {})
  • treats NaN as equal to NaN, and -0 as equal to 0

The first argument fixes the Val type accepted by the second, so comparing values with different brands is a type error.

equals always compares the stored structure. When different inputs mean the same value, normalize them in the seal. When the comparison means something other than value equality, give it a name in the companion.

Val.of

Brands a payload with the type named explicitly.

Val.of<User>({ id: "a", name: "alice" });

If the type has a seal of its own, use that instead. Val.of skips the checks as an escape hatch. Linting reports misuse of it, such as calling Val.of on a type that has a companion or specifying no type.

Val.unwrap

A plain, mutable deep copy of the payload, to pass to code that does not know about readonly. It strips the brand at every depth.

const post = Post({ title: "t", tags: ["a"] });

post.tags.sort(); // ✗ readonly string[] has no sort
Val.unwrap(post).tags.sort(); // ✓

It returns the payload as the type declares it, so a readonly written there survives the unwrap. Write the payload plain. See Allowed types.

Patterns

Framework state

A value is a plain object, so a state container holds it as it stands. Replace it whole. The untouched subtrees keep their identity, so a dependency array sees no change.

const [shop, setShop] = useState(Shop({ owner, city }));
setShop(Shop.patch(shop, { owner: { email: "e@example.com" } }));

useEffect(() => showMap(shop.city), [shop.city]); // the patch did not touch `city`: no re-run

Solid reads the same with createSignal, and takes the comparison directly: createSignal(user, { equals }).

Svelte and Vue are deeply reactive by default, so ask for a shallow container. A deep one gives your code a proxy in place of the value, and patch no longer recognizes the nodes it owns, so it copies them again.

Values are frozen in development, so Vue skips them and ref behaves like shallowRef until you build for production. A write through Vue’s ref or Solid’s createStore then mutates the value instead of throwing.

framework
ReactuseState
SolidcreateSignal, not createStore
Svelte 5$state.raw, not $state
VueshallowRef, not ref

Map / Set

Use an object’s properties.

type Tags = Val<"Tags", Record<string, true>>; // a Set
type PriceTable = Val<"PriceTable", Record<string, Money>>; // a Map

Use true rather than null for a set, so if (tags[key]) is the membership test. equals ignores key order, so comparing two of them is set equality.

patch sets one entry at a time, and undefined removes it. Call the constructor to rebuild the whole table.

PriceTable.patch(table, { apple: Money({ amount: 120, currency: "JPY" }), fig: undefined });

PriceTable(
  // the value type is named because a Val carries its phantom keys in the type as well
  Object.fromEntries(Object.entries<Money>(table).filter(([, m]) => m.amount < 500)),
);

Dates

export type UnixEpochMs = Val<"UnixEpochMs", number>;

export const UnixEpochMs = Val.sealer<UnixEpochMs>().impl({
  showLocal(d) {
    return Temporal.Instant.fromEpochMilliseconds(d).toLocaleString();
  },
});

API

Values

equals(a, b)deeply compares two Vals of the same type
Val.of<V>(value)applies the default seal with the type named
Val.of.nocopy<V>(value)makes a payload the value without copying
Val.unwrap(value)returns a mutable copy of the payload
Val.sealer<V>()creates a callable default sealer
Val.sealer<V>().impl(fns?)adds the type’s members, and ends the chain
Val.companion<V>()starts a companion without a callable sealer
Val.companion<V>().impl(fns?)the same, on a companion
.implTrait(Tr, fns?)implements a trait the type declares
.implTrait<Tr>(fns)the same, for a trait that implements nothing of its own
Val.companion<V>().implSeal(f)registers a custom seal
Val.companion<V>().implCreate(f)registers a function that creates a payload
Val.companion<V>().fixed<K>()excludes keys from patch

Companion members

User stands for the companion that belongs to the User type.

User(value)built with Val.sealer<User>()applies the default seal
User.nocopy(value)built with Val.sealer<User>()uses a deeply readonly payload without copy
User.seal(value)registered with .implSeal(f)applies the custom seal
User.seal.nocopy(value)registered with .implSeal(f)uses its non-copying terminal seal
User.create(...args)registered with .implCreate(f)creates a payload, then passes it to seal
User.create.nocopy(...args)registered with .implCreate(f)creates and seals without terminal copying
User.patch(user, patch)User has an object payloaddeeply merges the patch, then seals it
User[member](user, ...args)registered with .impl(fns)runs a member defined for the companion

Types

Val<K, T>a branded value type
AnyVala constraint over any Val
SeedOf<V>the payload accepted by constructors and seals
PayloadOf<V>the payload, with the brand removed at every depth
Patch<T>the patch accepted for a payload
Rec<V>a Val’s reference to itself, in its own payload
Sealer<V>a callable sealer with every step still open
Sealed<V, M>a finished callable companion
CompanionBuilder<V>a companion with every step still open
Companion<V, M>a finished companion

You never write the last four. They are exported so that your own .d.ts can name them when you re-export a companion.

valof/experimental

Warning

Experimental. The design is still changing. See Enums and Traits.

Enum

Enum<K, D, X>a closed set of variants, as one union
Enum.sealer<E>(tag?)starts an enum whose variants are callable
Enum.companion<E>(tag?)the same, for an enum with a seal of its own
E.match(value, handlers)dispatches on the tag, exhaustively
E[Variant](payload)builds that variant, writing the tag
E[Variant].create(payload)the same on a companion, through its seal
E[Variant].patch(value, patch)derives a variant, never reaching the tag
E(payload) / E.seal(payload)selects the variant from the tag, and seals
.impl(fns?)adds members taking the union, and ends the chain
.implVariant(N, sealer => …)builds one variant from its own steps
.implSeal(seal)replaces the seal every variant passes
.implTrait(Tr, fns?)implements a trait the enum declares
.implTrait<Tr>(fns)the same, for a trait that implements nothing of its own
Tag<T>names the tag field, intersected into X
VariantOf<E, N>the type of one variant
SeedFor<E, N>what that variant’s constructor takes
SealedPayload<E>what E(payload) takes, tag included
VariantsOf<E> / SharedOf<E>the declared variants, and the shared fields
NameOf<E> / TagOf<E>the enum’s name, and the tag field’s name
AnyEnuma constraint over any enum
EnumSealer<E> / EnumBuilder<E>an enum with every step still open
EnumSealed<E> / EnumCompanion<E>a finished enum companion

Trait

Trait<K, Shape, M>a contract Vals share
Trait.companion<Tr>()starts the trait’s own implementation
Tr.impl(fns)implements members over the trait, and ends the chain
Tr.dyn(companion, value)boxes a value with one Val’s implementation
Selfthe implementing Val, inside a member’s signature
Final<F>marks a member no Val may replace
Dyn<Tr>a boxed value, with the concrete type gone
AnyTraita constraint over any trait
Membersthe members a trait declares

Linting

Rules for the mistakes the type checker cannot catch. They run as a plugin for ESLint and Oxlint, and as a standalone command.

pnpm add -D oxc-parser   # valof does not install it for you

Rules

rulereportsdefault
unused-membera member registered with .impl or .implTrait that nothing readswarning
duplicate-branda brand string claimed by more than one top-level aliaserror
brand-mismatcha brand whose last segment is not the name of the type it brandserror
unnecessary-aliasa type alias that is a second name for a Val, a Trait or an Enumerror
unimplemented-traita Trait a Val or an Enum declares that its companion does not implementerror
companion-mismatcha companion, or a variant, bound to a name other than its ownerror
detached-implan impl step written outside the chain that declares its companionerror
split-companiona companion for a type that another file declareserror
bypassed-companiona Val.of for a type whose companion is how it is builtwarning
unnamed-ofa Val.of that names no type, taking one from its targetwarning
incomplete-disablea disable comment leaving out the rules it silences, or its scopeerror
unused-disablea disable comment naming a rule that reports nothing therewarning

A rule warns where the code around the finding still works, and errors where a Val is broken: two types the checker stops distinguishing, or a name that has to agree with another and does not. incomplete-disable errors for a reason of its own. A comment naming no rule silences every one, a rule added next year included.

The severity is what the plugin sets, and a project can give any rule its own. The command prints every finding the same way and exits 1 on any of them.

A member registered with .impl({…}) is not tree-shaken, and knip does not report it when it becomes unused.

Disable comments

Silence one line with a comment above it,

// valof-lint-disable-next-line unused-member -- public API
shout: (u) => u.toUpperCase(),

or a whole file with one anywhere in it:

// valof-lint-disable-whole-file unused-member -- every export here is public API
// valof-lint-disable-all-whole-file -- generated, do not lint

Name the rules it silences, separated by a space or a comma. The comments follow two rules of their own, incomplete-disable and unused-disable, which are left out of a run and given a severity like any other.

Plugin for ESLint and Oxlint

The same rules, one per finding kind. Name one to give it its own severity, or turn it off. The project to read is one setting for all of them, a path or a list of them, and defaults to src/**/*.ts. A path starting with ! is excluded from it.

ESLint needs a parser that reads your TypeScript.

// eslint.config.js
import valof from "valof/eslint-plugin";

export default [
  {
    plugins: { valof },
    rules: { ...valof.configs.recommended.rules, "valof/unused-member": "off" },
    settings: { valof: { project: ["src/**/*.ts", "!src/generated/**"] } },
  },
];

Oxlint takes the same plugin, through its JS plugins.

// oxlint.config.ts
import { defineConfig } from "oxlint";
import valof from "valof/eslint-plugin";

export default defineConfig({
  jsPlugins: ["valof/eslint-plugin"],
  extends: [valof.configs.recommended],
  rules: { "valof/unused-member": "off" },
  settings: { valof: { project: "src" } },
});

CLI

pnpm exec valof-lint src                                # the whole project
pnpm exec valof-lint src src/billing/id.ts              # report on the changed file
pnpm exec valof-lint 'src/**/*.ts' '!src/generated/**'  # leave a generated tree out
argumentwhat it is
the first paththe project to read, a directory or a glob
the paths after itthe files to report on, the whole project when there are none
--project, --report-onthe same two by name, in either order. Either can be repeated, and takes !path
--no-<rule>a rule to leave out of the run, by the rule name in the finding

A single file given as the project is refused, because a duplicate brand needs the other alias to be seen.

A !path is excluded wherever it is written, and is removed from the run rather than only from the report, so what a generated tree declares no longer applies to the rest.

Caveats

Return payloads across serialization boundaries

Return the payload, not the value. A generated client derives its response type from the handler, so a Val there arrives on the other side already typed as one, without having passed through the seal.

app.get("/user/:id", (c) => {
  const body: PayloadOf<User> = user; // the brand drops, the object is the same one
  return c.json(body);
});

Now the other side cannot use what arrives until it seals it:

const plain = await res.json(); // the generated client types this as PayloadOf<User>
const bad: User = plain; // type error: the brand is missing
const user = User(plain); // sealed, and now it is one

PayloadOf<V> removes the brand from the type, not from the value, so it costs nothing at run time. Val.unwrap copies and drops readonly too, which a request body does not need. Both also remove the brand of a nested Val.

Persistence helpers can create the same hole. Jotai’s atomWithStorage, for example, parses stored JSON and returns it as the type inferred from its initial value. Store a PayloadOf<User>, then seal it after reading.

Seal at the boundary, because the two sides deploy separately. The value was sealed by whichever build the server is running, and that seal may be older than yours.

Generic object utilities can bypass readonly and sealing

Object.assign accepts a readonly object as its target, so this passes the type checker:

Object.assign(user, { name: "mallory" });

Object.defineProperty and Reflect.set have the same problem. Valof freezes values in development, so these calls throw there. Production skips the freeze, and they mutate the Val.

Other utilities return a new object but preserve the input type. Immer, for example, makes a readonly input writable inside a callback:

const changed = produce(user, (draft) => {
  draft.name = "mallory";
}); // User

The result is still typed as User, although its seal never saw the change. Use User.patch to derive a value instead:

const changed = User.patch(user, { name: "mallory" });

When an API mutates its input, pass it Val.unwrap(user). When it returns a new payload, pass that payload to User or User.seal.

A __proto__ key survives sealing

A __proto__ key survives. It is a legal JSON key, and round-tripping JSON takes priority, so sealing keeps it as an own property rather than dropping data. That is inert inside a value, but not in code that merges a payload with Object.assign or a recursive merge. There, assigning the key sets a prototype instead of copying it. Sanitize untrusted input yourself.

Deeply nested payloads can overflow the stack

Copying and comparing are both recursive, so a payload a few thousand levels deep, or a cyclic one, throws a RangeError. Handle that error when sealing or comparing values from untrusted input.