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 - 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
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 = () => {
//
};
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. Prefer immutable update patterns unless mutation is necessary for performance reasons. Use readonly types to describe immutable APIs, but do not mistake them for runtime immutability.
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
// 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 mutation through that particular type, but it does not freeze the value or prove the value is actually immutable.
const mutable = [1, 2, 3];
const items: ReadonlyArray<number> = mutable;
mutable.push(4);
items; // [1, 2, 3, 4]
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. If a value is exposed as readonly, ensure it is produced immutably and that no mutable alias can still change it behind the scenes.
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
interfaceuntil you need to use features fromtype.
Evolu Type objects
For Evolu Type objects created with object(), use interface with InferType instead of type alias. TypeScript displays the interface name instead of expanding all properties.
import { object, String, Number, InferType } from "@evolu/common";
// Use interface for objects
const User = object({ name: String, age: Number });
interface User extends InferType<typeof User> {}
// Avoid - TypeScript expands all properties in tooltips
const User = object({ name: String, age: Number });
type User = typeof User.Type;
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 methods—otherwise 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
};
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
Disposable
Use Disposable when an object owns something that requires synchronous
cleanup.
Use DisposableStack for synchronous disposable helpers, even when they own
only one cleanup step.
Leverage that same stack with assertNotDisposed when synchronous public
methods become invalid after disposal.
This mirrors what C# does by default with
ObjectDisposedException: using a disposed object is a programmer error and should fail fast. In Evolu we enforce that behavior by convention with explicit assertNotDisposed guards.
/** Creates {@link RefCount}. */
export const createRefCount = (): RefCount => {
let count = zeroNonNegativeInt;
const stack = new DisposableStack();
stack.defer(() => {
count = zeroNonNegativeInt;
});
const moved = stack.move();
return {
increment: () => {
assertNotDisposed(moved);
const nextCount = PositiveInt.orThrow(count + 1);
count = nextCount;
return nextCount;
},
decrement: () => {
assertNotDisposed(moved);
assert(count > 0, "RefCount must not be decremented below zero.");
count = NonNegativeInt.orThrow(count - 1);
return count;
},
getCount: () => {
assertNotDisposed(moved);
return count;
},
[Symbol.dispose]: () => moved.dispose(),
};
};
AsyncDisposable
Use AsyncDisposable when an object owns async cleanup or long-lived async
work.
Use AsyncDisposableStack to own async resources and cleanup.
For reusable async resources, create one internal Run with run.create()
and use that Run for the resource's async operations. Disposing the resource
then disposes that internal Run, which aborts in-flight child tasks, waits
for them to settle, and rejects later calls through it.
If an AsyncDisposable object also exposes synchronous methods, guard those
methods with assertNotDisposed on the moved AsyncDisposableStack.
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.,
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
Composition without pipe
Evolu doesn't provide a pipe helper. Instead, compose functions directly:
// AWS-style retry - a jittered, capped, limited exponential backoff.
const awsRetry = jitter(1)(maxDelay("20s")(take(3)(exponential("1s"))));
If nested composition gets too deep, split into meaningful named parts:
// Split long compositions into named intermediate values
const limitedExponential = take(3)(exponential("1s"));
const cappedBackoff = maxDelay("20s")(limitedExponential);
const awsRetry = jitter(1)(cappedBackoff);
Shallow nesting often fits one line (like awsRetry). 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
Create fresh deps per test for isolation using testCreateDeps(). Each call returns independent instances, preventing shared state between tests.
import { testCreateDeps, createId } from "@evolu/common";
test("creates unique IDs", () => {
const deps = testCreateDeps();
const id1 = createId(deps);
const id2 = createId(deps);
expect(id1).not.toBe(id2);
});
Test factories naming
Test-specific factories use testCreateX prefix to distinguish from production createX:
// Production factory
const createTime = (): Time => ({ now: () => Date.now() });
// Test factory with controllable time
const testCreateTime = (options?: {
readonly startAt?: Millis;
readonly autoIncrement?: boolean;
}): TestTime => {
// implementation
};
The testCreateX prefix signals:
- Returns a test double, not a real implementation
- May have additional capabilities (e.g.,
advance()method ontestCreateTime) - Designed for deterministic, repeatable tests