Skip to content
jo16ohPublic

About

Value-object helpers for TypeScript: branded types, a constructor and a companion, kept as plain data.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Valof

Valof

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

npm   📘 The Valof Book

Important

Valof is pre-1.0. A minor release can change the API. Read the release notes before upgrading.

Installation

npm install valof

The recommended compiler options are strict (the default since TypeScript 6) and exactOptionalPropertyTypes.

Why Valof?

Valof is a TypeScript library for branded, immutable domain types, such as value objects and enums. It 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 seal copies its input, 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.1 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: 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. Linting shows the setup.

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

Read The Valof Book for more details.

Examples

import { Enum, Trait, Val, type Dyn, type Self } from "valof";

type Result<T> = { ok: T } | { err: string };

type TaskId = Val<"TaskId", string>;
const TaskId = Val.companion<TaskId>().build();

type Status = Enum<"Status", { Todo: { due: string }; Done: { at: string } }>;
const Status = Enum.companion<Status>().build();

type Summarizable = Trait<"Summarizable", { title: string }, { summarize: (self: Self) => string }>;
const Summarizable = Trait.companion<Summarizable>().build();

type Task = Val<"Task", { id: TaskId; status: Status; tags: string[] }, Summarizable>;
const Task = Val.companion<Task>()
  .implSeal((task, seal): Result<Task> =>
    task.title.trim() === "" ? { err: "empty title" } : { ok: seal(task) },
  )
  .impl(({ patch }) => ({
    complete: (task, at: string) => patch(task, { status: Status.Done.create({ at }) }),
  }))
  .implTrait(Summarizable, () => ({
    summarize: (t): string =>
      Status.match(t.status, {
        Todo: (s) => `${t.title} (due ${s.due})`,
        Done: () => `${t.title} (done)`,
      }),
  }))
  .build();

type Note = Val<"Note", { text: string }, Summarizable>;
const Note = Val.companion<Note>()
  .implTrait(Summarizable, () => ({ summarize: (n) => n.title }))
  .build();

function main() {
  const result = Task.seal({
    id: TaskId.seal("t1"),
    title: "Write the README",
    status: Status.Todo.create({ due: "10-31" }),
    tags: ["docs"],
  });

  if ("err" in result) throw result.err;
  const task = result.ok;

  task.status._tag; // "Todo"

  const done = Task.complete(task, "10-27");
  if ("err" in done) throw done.err;
  done.ok.status._tag; // "Done"
  done.ok.tags === task.tags; // true: structural sharing

  // Dynamic dispatch, as in Rust's `Box<dyn Trait>`. Each item calls its own type's `summarize`.
  const board: Dyn<Summarizable>[] = [
    Summarizable.dyn(Task, task),
    Summarizable.dyn(Note, Note.seal({ title: "Ideas", text: "..." })),
  ];
  board.map((item) => item.summarize()).forEach((s) => console.log(s)); // "Write the README (due 10-31)", "Ideas"
}

function errors(task: Task) {
  Task.seal({ ...task, title: " " }); // { err: "empty title" }
  // @ts-expect-error a plain string has no TaskId brand
  Task.seal({ ...task, id: "t2" });
  // @ts-expect-error Vals are deeply readonly
  task.tags.push("urgent");
}

Development

vp install              # install dependencies
vp test                 # run the tests
vp check                # format, lint, type check
vp run build            # build
vp run bundle-size      # measure what a user ships against its budget
vp run type-perf        # measure the type cost: instantiations and the declaration size
vp run ts-compatibility # type check the published .d.ts against every TypeScript line

License

MIT

Footnotes

  1. Two paths skip a companion's seal: Val.of and .implCreate.noseal. ↩

About

Value-object helpers for TypeScript: branded types, a constructor and a companion, kept as plain data.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages