Conventions

Conventions minimize decision-making and improve consistency.

Named imports

Use named imports. Refactor modules with excessive imports.

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

Unique exported members

Avoid namespaces. Use unique and descriptive names for exported members to prevent conflicts and improve clarity.

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

// Prefer
export const ok = ...;
export const trySync = ...;

// eqStrict
// eqBoolean
// orderString
// orderNumber
// isBetween
// isBetweenBigInt

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 the implementation and implementation details, we ensure that the developer-facing contract is immediately clear, making it easier to understand the purpose and structure of the code.

Another way to think about it is that we approach the code from the whole to the detail, like a painter painting a picture. The painter never starts with details but with the overall layout and gradually adds details.

// 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 = () => {
  //
};

Arrow functions

Use arrow functions instead of the function keyword.

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

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

Why arrow functions?

  • No hoisting - Combined with const, arrow functions aren't hoisted, which enforces top-down code organization
  • Consistency - One way to define functions means less cognitive overhead
  • Currying - Arrow functions make currying natural for dependency injection

Exception: function overloads. TypeScript requires the function keyword for overloaded signatures:

export function mapArray<T, U>(
  array: NonEmptyReadonlyArray<T>,
  mapper: (item: T) => U,
): NonEmptyReadonlyArray<U>;
export function mapArray<T, U>(
  array: ReadonlyArray<T>,
  mapper: (item: T) => U,
): ReadonlyArray<U>;
export function mapArray<T, U>(
  array: ReadonlyArray<T>,
  mapper: (item: T) => U,
): ReadonlyArray<U> {
  return array.map(mapper) as ReadonlyArray<U>;
}

Immutability

Mutable state is tricky because it increases the risk of unintended side effects, makes code harder to predict, and complicates debugging—especially in complex applications where data might be shared or modified unexpectedly. Favor immutable values using readonly types to reduce these risks and improve clarity.

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

The readonly helper

Use the readonly helper to cast arrays, sets, records, and maps to their readonly counterparts with zero runtime cost.

import { readonly, NonEmptyArray } from "@evolu/common";

// Array literals become NonEmptyReadonlyArray
const items = readonly([1, 2, 3]);
// Type: NonEmptyReadonlyArray<number>

// NonEmptyArray is preserved as NonEmptyReadonlyArray
const nonEmpty: NonEmptyArray<number> = [1, 2, 3];
const readonlyNonEmpty = readonly(nonEmpty);
// Type: NonEmptyReadonlyArray<number>

// Regular arrays become ReadonlyArray
const arr: Array<number> = getNumbers();
const readonlyArr = readonly(arr);
// Type: ReadonlyArray<number>

// Sets, Records, and Maps
const ids = readonly(new Set(["a", "b"]));
// Type: ReadonlySet<string>

const users: Record<UserId, string> = { ... };
const readonlyUsers = readonly(users);
// Type: ReadonlyRecord<UserId, string>

const lookup = readonly(new Map([["key", "value"]]));
// Type: ReadonlyMap<string, string>

Immutable helpers

Evolu provides helpers in the Array and Object modules that do not mutate and preserve readonly types.

Interface over type

Prefer 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

Was this page helpful?