Conventions

Conventions minimize decision-making and improve consistency.

Imports and exports

Use named exports and named imports.

import { bar, baz } from "Foo.ts";
export { bar, baz };

Avoid namespaces. Use unique names because Evolu re-exports everything through a single index.ts.

// Use
export const ok = () => {};
export const trySync = () => {};

// Avoid
export const Utils = { ok, trySync };

Naming conventions

  • Types — PascalCase without suffix: Eq, Order, Result, Millis
  • Type instances — type prefix + TypeSuffix: eqString, eqNumber, orderString, orderBigInt
  • Operations — verb + TypeSuffix: mapArray, filterSet, sortArray, addToSet
  • ConversionsxToY (often symmetric pairs): ownerIdToOwnerIdBytes/ownerIdBytesToOwnerId, durationToMillis
  • FactoriescreateX: createTime, createStore, createRun
  • Library-exported test helperstestX: testCreateDeps, testCreateRun, testCreateTime, testSetupSqlite
  • Empty constantsemptyX: emptyArray, emptySet, emptyRecord
  • PredicatesisX: isNonEmptyArray, isBetween, isBetweenBigInt
  • Accessors — position + InX: firstInArray, lastInArray, firstInSet
  • Indexed collections — value + By + key (vByK): rowsByQuery, messagesByOwnerId, usersById
  • DependenciesXDep: TimeDep, RandomDep, ConsoleDep
  • Domain errors — interface XError, discriminant "X": UserNotFoundError extends Typed<"UserNotFound">

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.

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.

// Public interface first: the contract developers rely on.
interface Foo {
  readonly bar: Bar;
}

// Supporting types next: details of the contract.
interface Bar {
  //
}

// Implementation after: how the contract is fulfilled.
const foo = () => {
  bar();
};

// Implementation details below the implementation, if any.
const bar = () => {
  //
};

Ordinary interfaces and types describe the happy-path contract, so place them before the code that uses them. A function-specific error describes the non-happy path: place its interface immediately after that function, separated by an empty line.

import { err, ok, type Result, type Typed } from "@evolu/common";

interface User {
  readonly id: string;
}

const getUser = (id: string): Result<User, UserNotFoundError> =>
  id === "user-1" ? ok({ id }) : err({ type: "UserNotFound" });

interface UserNotFoundError extends Typed<"UserNotFound"> {}

Immutability

Immutable values enable referential transparency: identity (===) implies equality. React and React Compiler rely on this for efficient rendering — prevValue !== nextValue detects changes without deep comparison.

// Mutable: same reference, different content
const mutableItems = [1, 2, 3];
mutableItems.push(4);
mutableItems === mutableItems; // true, but content changed

// Immutable: new reference signals change
const items = [1, 2, 3];
const newItems = [...items, 4];
items === newItems; // false

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 the result leaves that function. 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> and NonEmptyReadonlyArray<T> for arrays
  • ReadonlySet<T> for sets
  • ReadonlyRecord<K, V> for records
  • ReadonlyMap<K, V> for maps
// 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>;
}

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.

const mutable = [1, 2, 3];
const items: ReadonlyArray<number> = mutable;

mutable.push(4);
items; // [1, 2, 3, 4]

const mutateRecord = (value: Record<string, number>): void => {
  value.count = 1;
};

const record: Readonly<Record<string, number>> = {};
mutateRecord(record); // TypeScript allows this.

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 always appear by name in error messages and tooltips.

Use type only when necessary:

  • Union types: type Status = "pending" | "done"
  • Mapped types, tuples, or type utilities

Use interface until you need to use features from type.

TypeScript Handbook

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 { object, String, Number, type InferType } from "@evolu/common";

const User = object({ name: String, age: Number });
interface User extends InferType<typeof User> {}

Avoid a type alias because TypeScript expands all properties in tooltips and error messages:

import { object, String, Number } from "@evolu/common";

const User = object({ name: String, age: Number });
// Avoid: don't use a type alias for object Outputs.
type User = typeof User.Output;

Arrow functions

Use arrow functions instead of the function keyword.

// Use
const createUser = (data: UserData): User => {
  // implementation
};

// Avoid
function createUser(data: UserData): User {
  // implementation
}

Why arrow functions?

  • Consistency - One way to define functions means less cognitive overhead
  • Currying - Arrow functions make currying natural for dependency injection

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:

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) as ReadonlyArray<U>;
}

**In interfaces too.** Use arrow function syntax for interface methodsotherwise ESLint won't allow passing them as references due to JavaScript's `this` binding issues.

```ts
// Use arrow function syntax
interface Foo {
  readonly bar: (value: string) => void;
  readonly baz: () => number;
}

// Avoid method shorthand syntax
interface FooAvoid {
  bar(value: string): void;
  baz(): number;
}

Function options

For functions with optional configuration, use inline types without readonly for single-use options and named interfaces with readonly for reusable options. Always destructure immediately.

Inline types when options are single-use:

const race = (
  tasks: Tasks,
  {
    abortReason = raceLostError,
  }: {
    // The reason to abort losing tasks with.
    abortReason?: unknown;
  } = {},
): Task<T, E> => {
  // implementation
};

Named interfaces when options are reused:

interface RetryOptions {
  readonly maxAttempts?: number;
  readonly delay?: Duration;
  readonly backoff?: "linear" | "exponential";
}

const retry = (
  task: Task<T, E>,
  schedule: Schedule<unknown, E>,
  { maxAttempts = 3, delay = "1s", backoff = "exponential" }: RetryOptions,
): Task<T, RetryError<E>> => {
  // implementation
};

Switch exhaustiveness

Use exhaustiveCheck in the default branch of switches over union types. This makes missing cases fail at compile time and preserves a runtime guard if an unexpected value crosses a typed boundary.

Use it mainly for side-effect switches. For value-producing switches, prefer returning from every case and omitting default so TypeScript enforces exhaustiveness through the return type.

import { exhaustiveCheck } from "@evolu/common";

type Message =
  | { readonly type: "Create" }
  | { readonly type: "Update" }
  | { readonly type: "Delete" };

const handleMessage = (message: Message): void => {
  switch (message.type) {
    case "Create":
      break;
    case "Update":
      break;
    case "Delete":
      break;
    default:
      exhaustiveCheck(message);
  }
};

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.

// Use explicit methods for mutable internal state
interface Counter {
  readonly getValue: () => number;
  readonly increment: () => void;
}

// Avoid: This looks stable but if backed by a getter, value might change
interface CounterAvoid {
  readonly value: number;
  readonly increment: () => void;
}

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.

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.

interface Todo extends Typed<"Todo"> {
  readonly id: TodoId;
  readonly title: NonEmptyTrimmedString100;
  readonly isCompleted: boolean;
}

const completeTodo = (todo: Todo): Todo => ({
  ...todo,
  isCompleted: true,
});
const Todo = typed("Todo", {
  id: id("Todo"),
  title: NonEmptyTrimmedString100,
  isCompleted: Boolean,
});

interface Todo extends InferType<typeof Todo> {}
// Use interface + factory function
interface Counter {
  readonly getValue: () => number;
  readonly increment: () => void;
}

const createCounter = (): Counter => {
  let value = 0;
  return {
    getValue: () => value,
    increment: () => {
      value++;
    },
  };
};

// Avoid
class Counter {
  value = 0;
  increment() {
    this.value++;
  }
}

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

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 { Brand } from "@evolu/common";

type UserId = number & Brand<"UserId">;
type TrimmedName = string & Brand<"TrimmedName">;

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 { 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;
}

Opaque types are useful for:

  • Platform abstraction - Hide platform-specific details (e.g., NativeMessagePort wraps 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

Composition without pipe

Evolu doesn't provide a pipe helper. Instead, compose functions directly:

// AWS SDK for Java 2.1 ordinary-failure retry timing.
const retryStrategyAws = jitter("100%")(
  maxDelay("20s")(take(2)(exponential("50ms"))),
);

If nested composition gets too deep, split into meaningful named parts:

// Split long compositions into named intermediate values
const limitedExponential = take(2)(exponential("50ms"));
const cappedBackoff = maxDelay("20s")(limitedExponential);
const retryStrategyAws = jitter("100%")(cappedBackoff);

Shallow nesting often fits one line (like retryStrategyAws). If it doesn't, split — there's a good chance you'll reuse those named parts elsewhere.

Evolu favors imperative code over pipes. A well-named helper is more discoverable and self-documenting than a long chain of transformations.

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.

// Good - ok() means success, no redundant string needed
const save = (): Result<void, SaveError> => {
  // ...
  return ok();
};

// Good - return a meaningful value
const parse = (): Result<User, ParseError> => {
  // ...
  return ok(user);
};

// Avoid - "done" and "success" add no information
return ok("done");
return ok("success");

Testing

See Testing for test doubles, fresh dependency setup, deterministic Tasks, and test helper naming.