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

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.