Conventions
Conventions minimize decision-making and improve consistency.
This page and the guides it links to explain Evolu's coding conventions.
The repository's AGENTS.md is a selective summary for routine agent work,
with additional repository workflow instructions. Keep shared rules consistent;
not every convention needs a summary there. Agents should read only the relevant
documentation section when clarification is needed or when reviewing or changing
a convention.
Imports and exports
Use named exports and named imports.
Use a default export only when a framework or tool API requires one. Namespace imports are allowed for third-party namespace APIs.
import { ok, trySync } from "@evolu/common";
export { ok, trySync };
Avoid namespaces. Use unique names because Evolu re-exports everything through a single index.ts.
// Use: export each unique name.
export const emptyArray: ReadonlyArray<never> = [];
export const emptyRecord: Readonly<Record<string, never>> = {};
// Avoid: a namespace-like object hides the names from the shared index.
export const Empty = { array: emptyArray, record: emptyRecord };
Naming conventions
- Types — PascalCase:
Eq,Order,Result,Millis - Type instances — type prefix + TypeSuffix:
eqString,eqNumber,orderString,orderBigInt - Operations — verb + TypeSuffix:
mapArray,filterSet,sortArray,addToSet - Conversions —
xToY(often symmetric pairs):ownerIdToOwnerIdBytes/ownerIdBytesToOwnerId,durationToMillis - Factories —
createX:createTime,createStore,createRun - Library-exported test helpers —
testX:testCreateDeps,testCreateRun,testCreateTime,testSetupSqlite - Empty constants —
emptyX:emptyArray,emptySet,emptyRecord - Predicates —
isX:isNonEmptyArray,isBetween,isBetweenBigInt - Accessors — position +
InX:firstInArray,lastInArray,firstInSet - Indexed collections — value +
By+ key (vByK):rowsByQuery,messagesByOwnerId,usersById - Dependencies —
XDep:TimeDep,RandomDep,ConsoleDep - Domain errors — interface
XError; omitErrorfrom the discriminant only when the remaining name clearly describes a failure:UserNotFoundErrorextendsTyped<"UserNotFound">, whileTimeoutErrorkeeps"TimeoutError"
Reusable test setup helpers should be named after what they set up: use setupFoo
for local helpers and helpers shared from test-only files such as _deps.ts.
Use the test prefix only for test helpers exported from library modules.
Consistent prefixes enable discoverability—type map and autocomplete shows mapArray, mapSet, mapObject, mapSchedule without importing first.
Use globalThis for globals whose names overlap local APIs, such as
globalThis.Worker. Variable shadowing is allowed.
Order (top-down readability)
Many developers naturally write code bottom-up, starting with small helpers and building up to the public API. However, Evolu optimizes for reading, not writing, because source code is read far more often than it is written. By presenting the public API first—interfaces and types—followed by implementation and implementation details, the developer-facing contract is immediately clear.
Think of it like painting—from the whole to the detail. The painter never starts with details, but with the overall composition, then gradually refines.
Group declarations by feature. Finish each feature before starting the next; do not collect all exported declarations at the top of the module. Within a feature, put the public contract and its supporting types before the implementation. Shared helpers and private implementation types follow the implementation.
import { assertEqual } from "@evolu/common";
// Public interface first: the contract developers rely on.
interface Foo {
readonly bar: Bar;
}
// Supporting types next: details of the contract.
interface Bar {
readonly name: string;
}
// Implementation after: how the contract is fulfilled.
const foo: Foo = { bar: { name: "Evolu" } };
// Shared helpers and private implementation types follow, if needed.
assertEqual(foo.bar.name, "Evolu");
Error types are part of the public contract, just like success types. Place both before the implementation so readers can understand the possible outcomes before reading how the function works.
import {
assertErr,
assertOk,
err,
ok,
type Result,
type Typed,
} from "@evolu/common";
interface User {
readonly id: string;
}
interface UserNotFoundError extends Typed<"UserNotFound"> {}
const getUser = (id: string): Result<User, UserNotFoundError> =>
id === "user-1" ? ok({ id }) : err({ type: "UserNotFound" });
assertOk(getUser("user-1"), { id: "user-1" });
assertErr(getUser("user-2"), { type: "UserNotFound" });
Keep an inferred output interface immediately after its Evolu Type value, as shown under Interface over type.
Place orchestration before the lower-level operations it calls. There is one
runtime constraint: a const helper must be initialized before a module
initializer calls it, including through another function. Otherwise module
evaluation can fail before initialization is complete.
Do not extract a helper used only once; inline it. Keeping the operation in place lets the reader follow the code without jumping to another declaration. Meaningful local constants can explain intermediate values within that flow.
Immutability
Immutable data makes reference equality useful for detecting changes. When a reference stays the same, consumers can skip work because its contents have not changed. A new reference may contain equal data. Preserving the original reference for unchanged data can avoid unnecessary rendering and recomputation.
import { assertEqual, assertSame, assertTrue } from "@evolu/common";
// Mutable: the same reference now holds different content.
const mutableItems = [1, 2, 3];
const previousItems = mutableItems;
mutableItems.push(4);
assertSame(mutableItems, previousItems);
assertEqual(mutableItems, [1, 2, 3, 4]);
// Immutable: a new reference signals the change.
const items = [1, 2, 3];
const nextItems = [...items, 4];
assertTrue(items !== nextItems);
assertEqual(items, [1, 2, 3]);
assertEqual(nextItems, [1, 2, 3, 4]);
Mutation causes unintended side effects, makes code harder to predict, and complicates debugging. Evolu public functions do not mutate application data passed to them. Low-level APIs may mutate explicitly mutable values, such as buffers, when mutation is part of their contract. Prefer immutable update patterns for application data.
Local mutation is allowed as an implementation detail when useful for performance. A function may mutate a value it exclusively owns while constructing its result, but mutation must stop before returning it as immutable data. Explicitly mutable APIs may retain mutable state as part of their contract. Use readonly types to describe immutable APIs, but do not mistake them for runtime immutability or an ownership system.
Readonly types
Use readonly types for collections and prefix interface properties with readonly:
ReadonlyArray<T>andNonEmptyReadonlyArray<T>for arraysReadonlySet<T>for setsReadonlyRecord<K, V>for recordsReadonlyMap<K, V>for maps
import { assertEqual, assertTrue } from "@evolu/common";
// Use ReadonlyArray for immutable arrays.
const values: ReadonlyArray<string> = ["a", "b", "c"];
// Use readonly for interface properties.
interface Example {
readonly id: number;
readonly items: ReadonlyArray<string>;
readonly tags: ReadonlySet<string>;
}
const example: Example = { id: 1, items: values, tags: new Set(["a"]) };
assertEqual(example.items, ["a", "b", "c"]);
assertTrue(example.tags.has("a"));
// @ts-expect-error Index signature in type 'readonly string[]' only permits reading.
values[0] = "d";
// @ts-expect-error Cannot assign to 'id' because it is a read-only property.
example.id = 2;
Readonly in TypeScript is only a static constraint. It prevents direct mutation through that particular type, but it does not freeze the value or prove the value is actually immutable. A mutable alias can still change it, and TypeScript can allow a readonly object to be passed to a function accepting a mutable type.
import { assertEqual } from "@evolu/common";
const mutable = [1, 2, 3];
const items: ReadonlyArray<number> = mutable;
mutable.push(4);
assertEqual(items, [1, 2, 3, 4]);
const mutateRecord = (value: Record<string, number>): void => {
value.count = 1;
};
const record: Readonly<Record<string, number>> = {};
// TypeScript accepts the readonly record for the mutable parameter.
mutateRecord(record);
assertEqual(record, { count: 1 });
Treat readonly types as a contract for APIs that already maintain immutability. Do not cast mutable values to readonly just to satisfy the type checker. A value may be built through local mutation and then returned as readonly, provided that it is not mutated after leaving the constructing function.
Evolu also provides helpers in the Array and Object modules that do not mutate and preserve readonly types.
Interface over type
Use interface over type because interfaces retain their names in error
messages and tooltips, while type aliases can be expanded into their underlying
types.
Use type only when necessary:
- Union types:
type Status = "pending" | "done" - Mapped types, tuples, or type utilities
- Intersections that compose dependencies, such as
ConsoleDep & TimeDep
Use
interfaceuntil you need to use features fromtype.
Evolu Type objects
For Evolu Type objects created with object() or typed(), use an interface with InferType. TypeScript displays the interface name instead of expanding all properties.
import {
Number,
String,
assertOk,
object,
type InferType,
} from "@evolu/common";
const User = object({ name: String, age: Number });
interface User extends InferType<typeof User> {}
const user: User = { name: "Ada", age: 36 };
assertOk(User.fromUnknown(user), user);
Avoid the type alias type User = typeof User.Output because TypeScript expands
all properties of a type alias in tooltips and error messages.
Arrow functions
Use arrow functions instead of the function keyword.
import { assertEqual } from "@evolu/common";
interface User {
readonly name: string;
}
const createUser = (name: string): User => ({ name });
assertEqual(createUser("Ada"), { name: "Ada" });
Do not write function createUser(name: string): User { ... }. The shared lint
configuration reserves function declarations for overloads.
Why arrow functions?
- Consistency - Functions and readonly function-valued interface properties use the same concise syntax
- No dynamic
this- Arrow functions retain their lexicalthiswhen passed around as values - Conciseness - Callbacks, small functions, and currying need little syntax
Exception: function overloads. While overloading with arrow functions is possible (using a type with multiple call signatures), it can be hard to type properly because the implementation must satisfy all overloads at once, which TypeScript often can't verify without assertions. Use the function keyword instead:
import {
assertEqual,
assertType,
type NonEmptyReadonlyArray,
} from "@evolu/common";
function mapArray<T, U>(
array: NonEmptyReadonlyArray<T>,
mapper: (item: T) => U,
): NonEmptyReadonlyArray<U>;
function mapArray<T, U>(
array: ReadonlyArray<T>,
mapper: (item: T) => U,
): ReadonlyArray<U>;
function mapArray<T, U>(
array: ReadonlyArray<T>,
mapper: (item: T) => U,
): ReadonlyArray<U> {
return array.map(mapper);
}
const nonEmpty: NonEmptyReadonlyArray<number> = [1, 2];
const doubled = mapArray(nonEmpty, (value) => value * 2);
assertType<typeof doubled, NonEmptyReadonlyArray<number>>();
assertEqual(doubled, [2, 4]);
In interfaces too. Use readonly function-valued properties rather than
method shorthand such as bar(value: string): void. This keeps contracts
consistent with our arrow-function implementations.
import { assertEqual } from "@evolu/common";
interface Foo {
readonly bar: (value: string) => void;
readonly baz: () => number;
}
const values: Array<string> = [];
const foo: Foo = {
bar: (value) => {
values.push(value);
},
baz: () => values.length,
};
foo.bar("a");
assertEqual(foo.baz(), 1);
Function options
For functions with optional configuration, inline single-use, non-exported
options types without readonly. Use a named interface with readonly
properties when the options type is exported or used by more than one function.
Destructure options in the parameter list.
Inline types when options are single-use and not exported:
import { assertEqual } from "@evolu/common";
const formatName = (
name: string,
{
trim = true,
}: {
// Whether surrounding whitespace is removed.
trim?: boolean;
} = {},
): string => (trim ? name.trim() : name);
assertEqual(formatName(" Ada "), "Ada");
assertEqual(formatName(" Ada ", { trim: false }), " Ada ");
Named interfaces when options are exported or reused:
import { assertEqual, durationToMillis, type Duration } from "@evolu/common";
export interface BackoffOptions {
readonly maxAttempts?: number;
readonly delay?: Duration;
readonly backoff?: "linear" | "exponential";
}
export const backoffDelays = ({
maxAttempts = 3,
delay = "1s",
backoff = "exponential",
}: BackoffOptions = {}): ReadonlyArray<number> => {
const base = durationToMillis(delay);
const delays: Array<number> = [];
for (let attempt = 0; attempt < maxAttempts; attempt++) {
delays.push(
backoff === "linear" ? base * (attempt + 1) : base * 2 ** attempt,
);
}
return delays;
};
assertEqual(backoffDelays(), [1000, 2000, 4000]);
assertEqual(backoffDelays({ backoff: "linear", maxAttempts: 2 }), [1000, 2000]);
Switch exhaustiveness
In a side-effecting switch over a union, call exhaustiveCheck in default.
This makes missing cases fail at compile time and preserves a runtime guard if
an unexpected value crosses a typed boundary.
For value-producing switches, return from every case and omit default so TypeScript enforces
exhaustiveness through the return type.
import { assertEqual, exhaustiveCheck } from "@evolu/common";
type Message =
| { readonly type: "Create" }
| { readonly type: "Update" }
| { readonly type: "Delete" };
const handledTypes: Array<string> = [];
// Side-effecting switch: exhaustiveCheck guards the default branch.
const handleMessage = (message: Message): void => {
switch (message.type) {
case "Create":
handledTypes.push("created");
break;
case "Update":
handledTypes.push("updated");
break;
case "Delete":
handledTypes.push("deleted");
break;
default:
exhaustiveCheck(message);
}
};
// Value-producing switch: every case returns, so no default is needed.
const messageToVerb = (message: Message): string => {
switch (message.type) {
case "Create":
return "create";
case "Update":
return "update";
case "Delete":
return "delete";
}
};
handleMessage({ type: "Create" });
assertEqual(handledTypes, ["created"]);
assertEqual(messageToVerb({ type: "Delete" }), "delete");
Avoid getters and setters
Avoid JavaScript getters and setters. Use simple readonly properties for stable values and explicit methods for values that may change.
Getters break the readonly contract. In Evolu, readonly properties signal stable values you can safely cache or pass around. A getter disguised as a readonly property violates this expectation—it looks stable but might return different values on each access.
Setters hide mutation and conflict with readonly. Evolu uses readonly properties everywhere for immutability. Setters are incompatible with this approach and make mutation invisible—obj.value = x looks like simple assignment but executes arbitrary code.
Use explicit methods instead. When a value can change or requires computation, use a method like getValue(). The parentheses signal "this might change or compute something" and make the behavior obvious at the call site. A readonly property like readonly id: string communicates stability—you can safely cache, memoize, or pass the value around knowing it won't change behind your back.
import { assertEqual } from "@evolu/common";
interface Counter {
// Stable: safe to cache.
readonly id: string;
// Changes over time: an explicit function signals it.
readonly getValue: () => number;
readonly increment: () => void;
}
const createCounter = (id: string): Counter => {
let value = 0;
return {
id,
getValue: () => value,
increment: () => {
value++;
},
};
};
const counter = createCounter("clicks");
const cachedValue = counter.getValue();
counter.increment();
assertEqual(counter.id, "clicks");
assertEqual(cachedValue, 0);
assertEqual(counter.getValue(), 1);
Avoid readonly value: number backed by a getter. It looks stable, but its
value can change on every access.
Functions over classes
Use interfaces with factory functions instead of classes. This keeps the public API separate from the implementation so the interface can describe the whole contract without mixing in state and method bodies.
Name factories createX. Inside a factory, declare items in this order:
- Derived constants and assertions.
- Mutable variables.
DisposableStack,AsyncDisposableStack, and other owned resources.- Listeners and timers.
- Local functions.
- The returned API.
The initialization constraint also applies here: initialize a const helper
before any setup code calls it synchronously.
Evolu favors composition over class inheritance. When inheritance is useful, an interface can extend multiple interfaces, which is more flexible than a class hierarchy.
Classes also bring this binding, constructor semantics, and visibility rules
that do not add much value in this codebase.
The same applies to domain objects. Evolu does not model domain entities as
classes with methods. We model them as plain data described by interfaces.
When a domain object is a tagged union member, extend
Typed<T>. When it needs
runtime validation or transport as JSON, define it with
typed(...) or
object(...).
For behavior, prefer plain functions that take the previous state and return the next state instead of mutating an instance.
import {
NonEmptyTrimmedString100,
assertFalse,
assertTrue,
createIdFromString,
id,
type Typed,
} from "@evolu/common";
const TodoId = id("Todo");
type TodoId = typeof TodoId.Output;
interface Todo extends Typed<"Todo"> {
readonly id: TodoId;
readonly title: NonEmptyTrimmedString100;
readonly isCompleted: boolean;
}
const completeTodo = (todo: Todo): Todo => ({
...todo,
isCompleted: true,
});
const todo: Todo = {
type: "Todo",
id: TodoId.orThrow(createIdFromString("todo")),
title: NonEmptyTrimmedString100.orThrow("Buy milk"),
isCompleted: false,
};
const completedTodo = completeTodo(todo);
assertFalse(todo.isCompleted);
assertTrue(completedTodo.isCompleted);
import {
Boolean,
NonEmptyTrimmedString100,
assertEqual,
assertOk,
createIdFromString,
id,
typed,
type InferType,
} from "@evolu/common";
const Todo = typed("Todo", {
id: id("Todo"),
title: NonEmptyTrimmedString100,
isCompleted: Boolean,
});
interface Todo extends InferType<typeof Todo> {}
const result = Todo.fromUnknown({
type: "Todo",
id: createIdFromString("todo"),
title: "Buy milk",
isCompleted: false,
});
assertOk(result);
const todo: Todo = result.value;
assertEqual(todo.title, "Buy milk");
import { assertEqual } from "@evolu/common";
// Use an interface with a factory function.
interface Counter {
readonly getValue: () => number;
readonly increment: () => void;
}
const createCounter = (step = 1): Counter => {
// Mutable variables stay private to the factory.
let value = 0;
// The returned API is the only way to observe or change them.
return {
getValue: () => value,
increment: () => {
value += step;
},
};
};
const counter = createCounter(2);
counter.increment();
assertEqual(counter.getValue(), 2);
Avoid a class Counter with a public value field and an increment()
method. The class exposes its state and mixes the contract with the
implementation.
Disposing
Create disposable objects with
disposable. It
adds the appropriate disposal method and guards the object's functions against
use after disposal.
When the object owns cleanup resources, register them in a DisposableStack or
AsyncDisposableStack and pass the stack to disposable. The helper moves the
stack into the returned object. Omit the stack when disposal only needs to make
the object unusable.
For full disposal patterns, anti-patterns, and move() examples, see
Resource management.
Branded types
Validate external input with Evolu Types. A cast or assertion does not replace
decoding untrusted data. Construct Types with factories such as createType, brand,
array, and object.
Use Brand to give
otherwise identical values distinct meaning at the type level. Branding lets us
separate domain concepts without changing the runtime representation. For
example, PositiveInt is still a number at runtime, but it is not
interchangeable with an arbitrary number in the type system.
import { type Brand } from "@evolu/common";
type UserId = number & Brand<"UserId">;
type TrimmedName = string & Brand<"TrimmedName">;
const describeUser = (_id: UserId, _name: TrimmedName): void => {};
// @ts-expect-error A plain number and string are not assignable to the branded UserId and TrimmedName parameters.
describeUser(1, "Ada");
Prefer Evolu Type brands over raw primitives when the value has domain
meaning. Do not create domain brands with plain as casts. Define a
validated Type with brand(...)
so the constraint is enforced and the branded value can only be obtained
through validation.
Opaque types
Opaque types are the standalone-brand case: a Brand with no base type. Use
them when callers should not inspect or construct values directly and can only
pass them back to the API that created them.
import { assertEqual, type Brand } from "@evolu/common";
// Opaque type: standalone brand with no exposed representation.
type TimeoutId = Brand<"TimeoutId">;
interface Timer {
readonly setTimeout: (fn: () => void, ms: number) => TimeoutId;
readonly clearTimeout: (id: TimeoutId) => void;
}
// Only the creating API knows the representation behind the brand.
const timer: Timer = {
setTimeout: (fn) => {
fn();
const handle: unknown = 1;
return handle as TimeoutId;
},
clearTimeout: () => {},
};
let calls = 0;
const timeoutId = timer.setTimeout(() => {
calls++;
}, 0);
timer.clearTimeout(timeoutId);
assertEqual(calls, 1);
// @ts-expect-error A number is not a TimeoutId.
timer.clearTimeout(1);
Opaque types are useful for:
- Platform abstraction - Hide platform-specific details (e.g.,
NativeMessagePortwraps browser/Node MessagePort) - Handle types - IDs that should only be passed back to the creating API (e.g., timeout IDs, file handles)
- Type safety - Prevent accidental misuse by making internal structure inaccessible
Symbol identity
Symbols used as keys in exported types must retain their unique identity in
emitted declarations. globalThis.Symbol() can infer symbol instead of
unique symbol, causing computed properties to disappear from emitted
declarations and erase type distinctions. Follow the explicit unique-symbol
declaration pattern in the repository's Type.ts and verify the emitted
declarations.
Define shared symbol keys at module scope so objects and the code reading them
use the same runtime key. Creating a key with Symbol() inside a function gives
each call a different symbol, but TypeScript associates its unique type with
the declaration. It can therefore accept an object from another call and allow
reading a required property that is actually missing at runtime. Sharing one
module-level key prevents this separate mismatch.
Runtime-only symbols used as sentinels or identity tokens do not need unique types.
Composition without pipe
Evolu doesn't provide a pipe helper. Instead, compose functions directly:
import {
assertType,
exponential,
jitter,
maxDelay,
take,
type Millis,
type Schedule,
} from "@evolu/common";
// AWS SDK for Java 2.1 ordinary-failure retry timing.
const retryStrategyAws = jitter("100%")(
maxDelay("20s")(take(2)(exponential("50ms"))),
);
assertType<typeof retryStrategyAws, Schedule<Millis>>();
If nested composition gets too deep, split into meaningful named parts:
import {
assertType,
exponential,
jitter,
maxDelay,
take,
type Millis,
type Schedule,
} from "@evolu/common";
// Split long compositions into named intermediate values.
const limitedExponential = take(2)(exponential("50ms"));
const cappedBackoff = maxDelay("20s")(limitedExponential);
const retryStrategyAws = jitter("100%")(cappedBackoff);
assertType<typeof retryStrategyAws, Schedule<Millis>>();
Evolu favors imperative code and direct composition over pipes. Use meaningful local constants when nesting becomes hard to read; keep single-use operations inline.
Result and errors
Fallible public APIs return Result<T, E> for typed domain errors. Represent
domain errors as exact plain objects, not Error instances. Name their
interfaces XError. Omit the Error suffix from the discriminant only when
the remaining name already clearly describes a failure, as in
UserNotFoundError extends Typed<"UserNotFound">. Keep it for names such as
TimeoutError, RetryError, and AbortError.
Use trySync and tryAsync to convert thrown or rejected values into a Result.
Use getOrThrow and Type .orThrow for module initialization, startup and
configuration loading, test fixtures, or internal invariants. Do not use them
to process user input.
See the Result module for composition and error-handling examples.
Avoid meaningless ok values
Don't use ok("done") or ok("success") — the ok() itself already communicates success. Use ok() for Result<void, E> or return a meaningful value.
import {
assertErr,
assertOk,
err,
ok,
type Result,
type Typed,
} from "@evolu/common";
interface User {
readonly id: string;
}
interface SaveError extends Typed<"SaveError"> {}
// Good: ok() means success, no redundant string needed.
const save = (_user: User): Result<void, SaveError> => ok();
interface ParseError extends Typed<"ParseError"> {}
// Good: return a meaningful value.
const parse = (input: string): Result<User, ParseError> =>
input === "" ? err({ type: "ParseError" }) : ok({ id: input });
assertOk(save({ id: "user-1" }), undefined);
assertOk(parse("user-1"), { id: "user-1" });
assertErr(parse(""), { type: "ParseError" });
Avoid ok("done") and ok("success"); the strings add no information.
Testing
Every feature addition and bug fix includes a test that fails without the change. Create fresh dependencies in each test. Cover the changed behavior, including relevant branches and failure paths. Report pre-existing coverage gaps without adding unrelated tests solely to reach 100% coverage of an entire source file.
Use assertType for compile-time contracts and @ts-expect-error for rejected
programs. Every @ts-expect-error must describe the specific expected rejection.
If an Evolu API supplies a CompileTimeError message, copy it verbatim;
otherwise, state the rejected TypeScript contract precisely.
See Testing for test doubles, fresh dependency setup, deterministic Tasks, and test helper naming.
Dependency injection and Tasks
Synchronous functions with injected dependencies accept one deps object.
Describe each dependency and its XDep wrapper with interfaces. Wrappers give
dependencies distinct property names, preventing clashes when combining them.
Dependency interfaces do not use generic parameters and expose domain errors,
not implementation-specific errors.
Use type aliases for intersections that compose dependencies. Sort dependencies
alphabetically and put Partial dependencies last. A caller may pass more
dependencies than a function requires, but a function must not require
dependencies it does not use.
Shared modules do not export dependency instances. Create and wire them in the composition root, where module-level instances are allowed.
Tasks declare dependencies in Task<T, E, D> and read them through run.deps.
Call Tasks with run(task), never task(run). Return, handle, or translate an
Err before accessing the result's value. An object exposing multiple
asynchronous operations creates one internal Run that every asynchronous
method uses.
See Dependency injection for synchronous examples and the Task module for asynchronous composition.
Documentation
JSDoc should explain behavior and intent without repeating TypeScript parameter
or return types. Do not use @param, @return, or @example. Put examples
under a ### Example Markdown heading. Use {@link} on the first mention of
an exported symbol. Avoid pipe characters in the first sentence because TypeDoc
inserts that sentence into Markdown tables. Do not make alignment-only JSDoc
edits.
Executable examples
Write every TypeScript code fence as a standalone, deterministic example that
testJSDocExamples can lint, compile, and run. Explicitly import dependencies
and assertions. Prefix intentionally unused declarations with _; the harness
rejects both unprefixed unused declarations and used underscore-prefixed
declarations.
Prove the documented contract with assertions instead of describing expected
output only in comments. Use assertType for static contracts, assertEqual
for Data comparisons, assertSame for SameValue or reference identity,
assertTrue or assertFalse for predicates, assert with a descriptive message
for invariants and narrowing, and assertOk or assertErr for Results.
After changing examples, run pnpm test:jsdoc <changed-file>. The repository's
pnpm verify runs the configured documentation-example suite. Only explicitly
configured sources are included automatically; a passing suite does not prove
that every documentation page was tested.
API reference organization
Use TypeDoc's generated declaration-kind groups for simple modules. If a module
uses custom @group tags, assign every exported declaration to a semantic
group. Do not mix custom groups with generated groups such as Functions,
Interfaces, and Type Aliases.
Name custom groups by their purpose within the module, using short contextual
names such as Creation, Guards, Model, or Pull, and define their order
explicitly. Keep module prose before generated API groups, with FAQ last when
present. Put API-specific guidance and examples on the relevant declarations.
Do not use @groupDescription or custom renderer logic to reposition module
prose.
TypeDoc warnings fail CI. Resolve every warning emitted by pnpm build:docs.