Introduction
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
ascasts 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:
patchcopies only the paths it changes. Untouched branches keep their reference identity. -
Rust-like abstractions (experimental):
Enumis a closed set of variants with exhaustivematch.Traitshares behavior across Vals.dynadds 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
.nocopyis 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:
| Primitives | string / number / boolean / bigint; null below the top level |
| Arrays | allowed[], 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.
Collect related functions in a companion object
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/experimentalso 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: 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/experimentalso 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.
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 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.
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 })).
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.
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 register 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,sealorcreate, which the library defines- anything under
__valof_or starting withimpl dyn, which the trait itself usesthen, which would make the companion a thenable
Each is reported where the trait is declared.
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
NaNas equal toNaN, and-0as equal to0
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 as well.
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 | |
|---|---|
| React | useState |
| Solid | createSignal, not createStore |
| Svelte 5 | $state.raw, not $state |
| Vue | shallowRef, 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 |
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 payload | deeply 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 |
AnyVal | a constraint over any Val |
SeedOf<V> | the payload accepted by constructors and seals |
PayloadOf<V> | the payload behind the brand |
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 |
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 |
AnyEnum | a 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 |
Self | the 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 |
AnyTrait | a constraint over any trait |
Members | the 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
| rule | reports | default |
|---|---|---|
unused-member | a member registered with .impl or .implTrait that nothing reads | warning |
duplicate-brand | a brand string claimed by more than one top-level alias | error |
brand-mismatch | a brand whose last segment is not the name of the type it brands | error |
unnecessary-alias | a type alias that is a second name for a Val, a Trait or an Enum | error |
unimplemented-trait | a Trait a Val or an Enum declares that its companion does not implement | error |
companion-mismatch | a companion, or a variant, bound to a name other than its own | error |
detached-impl | an impl step written outside the chain that declares its companion | error |
split-companion | a companion for a type that another file declares | error |
bypassed-companion | a Val.of for a type whose companion is how it is built | warning |
unnamed-of | a Val.of that names no type, taking one from its target | warning |
incomplete-disable | a disable comment leaving out the rules it silences, or its scope | error |
unused-disable | a disable comment naming a rule that reports nothing there | warning |
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
| argument | what it is |
|---|---|
| the first path | the project to read, a directory or a glob |
| the paths after it | the files to report on, the whole project when there are none |
--project, --report-on | the 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: 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.
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.