Skip to content

Coding Guidelines

These standards are mandatory for all code in the @stateloom/* monorepo. Every contributor (human or AI agent) must follow them.

TypeScript: Strictest Mode

Compiler Configuration

The project uses the strictest TypeScript configuration. See tsconfig.base.json:

  • strict: true (enables all strict family flags)
  • exactOptionalPropertyTypes: true
  • noUncheckedIndexedAccess: true
  • noImplicitOverride: true
  • noPropertyAccessFromIndexSignature: true
  • verbatimModuleSyntax: true
  • isolatedModules: true

JS Spec Only — No TypeScript-Specific Runtime Features

StateLoom uses TypeScript exclusively as a type system. No TypeScript-only runtime constructs are allowed.

Forbidden

ConstructWhyUse Instead
enumNot in JS spec, generates runtime code, poor tree-shakingas const objects
namespaceLegacy TS construct, not in JS specES modules
declare globalPollutes global scopeModule-scoped types
/// <reference>Legacy directiveimport type
Parameter properties (constructor(private x))TS-only syntaxExplicit field declaration
abstract classPrefer composition over inheritanceInterfaces + factory functions
Decorators (legacy TS)Use only TC39 Stage 3 decorators if neededFunctions / HOFs

Required Patterns

Enums → as const objects:

typescript
// WRONG
enum Status {
  Active = 'active',
  Inactive = 'inactive',
}

// CORRECT
const Status = {
  Active: 'active',
  Inactive: 'inactive',
} as const;

type Status = (typeof Status)[keyof typeof Status];
// type Status = 'active' | 'inactive'

Discriminated unions over class hierarchies:

typescript
// WRONG
abstract class BaseResult {
  abstract type: string;
}
class Success extends BaseResult {
  type = 'success';
  data: unknown;
}
class Failure extends BaseResult {
  type = 'failure';
  error: Error;
}

// CORRECT
type Result<T> =
  | { readonly type: 'success'; readonly data: T }
  | { readonly type: 'failure'; readonly error: Error };

Import Conventions

typescript
// Type-only imports (enforced by eslint and verbatimModuleSyntax)
import type { Signal, ReadonlySignal } from '@stateloom/core';

// Inline type imports when mixing values and types
import { signal, type SignalOptions } from '@stateloom/core';

// Value imports
import { computed, effect } from '@stateloom/core';

// Order: external deps → @stateloom/* → relative
import { produce } from 'immer';
import { signal } from '@stateloom/core';
import { createMiddleware } from './middleware.js';

Naming Conventions

EntityConventionExample
Fileskebab-case.tscreate-store.ts
FunctionscamelCasecreateStore()
Types/InterfacesPascalCaseStoreApi<T>
ConstantsSCREAMING_SNAKE_CASE or camelCaseDEFAULT_OPTIONS, defaultOptions
Type parametersSingle uppercase letter or descriptive PascalCaseT, TState
Private fields# prefix (ES private)#version
Boolean variablesis/has/should/can prefixisDirty, hasSubscribers
Factory functionscreate prefixcreateStore(), createScope()
Hook functions (React)use prefixuseStore(), useSignal()

Code Structure

Do

  • Keep functions small and focused on a single responsibility
  • Use descriptive, domain-specific names — code should read like prose
  • Prefer early returns and flat control flow over deep nesting
  • Split modules by concern: one file = one purpose
  • Use readonly on all properties that should not be mutated
  • Prefer interface for public API shapes, type for unions and intersections
  • Use unknown over any — narrow with type guards
  • Prefer composition over inheritance
  • Use factory functions over classes when possible
  • Keep comments minimal — explain why, not what

Don't

  • Don't use any — the ESLint rule @typescript-eslint/no-explicit-any is set to error
  • Don't use as type assertions unless absolutely necessary (prefer type narrowing)
  • Don't use non-null assertions (!) — handle nullability explicitly
  • Don't use @ts-ignore or @ts-expect-error without a comment explaining why
  • Don't create god objects or mega-functions that mix concerns
  • Don't rely on implicit side effects or shared mutable state between modules
  • Don't use default exports — named exports improve refactoring and tree-shaking
  • Don't use console.log in library code (use DevTools middleware instead)

JSDoc Standards

Every exported function, interface, type, and constant must have JSDoc documentation. This is the primary in-editor documentation for consumers — it should be thorough enough that developers never need to leave their editor.

Required JSDoc for Exported Functions

typescript
/**
 * Create a mutable reactive value container.
 *
 * Signals are the fundamental writable primitive in StateLoom. They hold a
 * value and notify dependents when that value changes. Equality is checked
 * with `Object.is` by default — identical values do not trigger notifications.
 *
 * @param initialValue - The initial value of the signal.
 * @param options - Optional configuration.
 * @param options.equals - Custom equality function. Defaults to `Object.is`.
 * @returns A writable signal with `get()`, `set()`, `update()`, and `subscribe()` methods.
 *
 * @example
 * ```typescript
 * const count = signal(0);
 * count.get();              // 0
 * count.set(5);             // notifies dependents
 * count.update((n) => n + 1); // 6
 * ```
 *
 * @example Custom equality
 * ```typescript
 * const user = signal(
 *   { name: 'Alice', age: 30 },
 *   { equals: (a, b) => a.name === b.name },
 * );
 * ```
 *
 * @remarks
 * - Calling `set()` inside a `batch()` defers notifications until the batch completes.
 * - Signals implement `Subscribable<T>` and work directly with framework adapters.
 * - Signal reads inside `computed()` or `effect()` are automatically tracked.
 */
export function signal<T>(initialValue: T, options?: SignalOptions<T>): Signal<T>;

Required JSDoc for Interfaces

typescript
/**
 * A writable reactive value container.
 *
 * Signals hold a value and notify subscribers when it changes.
 * They form the foundation of the StateLoom reactive graph.
 *
 * @typeParam T - The type of the value held by the signal.
 */
export interface Signal<T> extends ReadonlySignal<T> {
  /**
   * Set a new value. Notifies dependents if the value changed
   * according to the equality function (`Object.is` by default).
   *
   * @param value - The new value to set.
   */
  set(value: T): void;

  /**
   * Update the value using a function of the current value.
   * Equivalent to `signal.set(fn(signal.get()))`.
   *
   * @param fn - A function that receives the current value and returns the new value.
   */
  update(fn: (current: T) => T): void;
}

Required JSDoc for Constants

typescript
/**
 * Consumer is up-to-date — no recomputation needed.
 * @internal
 */
export const CLEAN = 0;

JSDoc Rules

ElementRequired Tags
Exported functionSummary, @param (all params), @returns, @example (at least 1)
Exported interface/typeSummary, @typeParam (if generic), method docs with @param/@returns
Exported constantSummary (one-line), @internal if not public API
Internal functionOne-line /** summary */ (no @example needed)
Internal interface/typeOne-line /** summary */

JSDoc Style

  • Start with a verb: "Create", "Return", "Track", "Check" — not "This function creates..."
  • Use @remarks for behavioral notes (scheduling, equality semantics, error conditions)
  • Use @example with triple-backtick TypeScript code blocks
  • Use @see to link to related functions: @see {@link computed} for derived values
  • Use @internal for exports that are not part of the public API
  • Use @typeParam for generic parameters on interfaces and types
  • Do not use @throws — document error behavior in @remarks

Error Handling

typescript
// Use typed errors with discriminated unions
type StoreError =
  | { readonly code: 'SCOPE_NOT_FOUND'; readonly message: string }
  | { readonly code: 'INVALID_STATE'; readonly message: string; readonly path: string };

// Throw only at system boundaries; return errors at internal boundaries
function validateState(state: unknown): Result<ValidState, StoreError> {
  // ...
}

Immutability Patterns

typescript
// Prefer readonly types
interface StoreState {
  readonly count: number;
  readonly items: readonly Item[];
}

// Prefer Object.freeze for runtime immutability in snapshots
const frozen = Object.freeze({ ...state });

// Use Readonly<T> utility type
function getSnapshot<T>(state: T): Readonly<T> {
  return Object.freeze({ ...state });
}

Performance Patterns

Performance matters most in hot paths — signal reads, computed evaluations, subscriber notifications, and dependency tracking. Follow these rules:

Allocation Discipline

  • Never allocate in hot paths. No new Object, new Array, new Map, or object/array literals inside signal get(), computed fn(), or subscriber notification loops.
  • Reuse objects instead of creating new ones in loops. Pre-allocate and mutate in place when safe.
  • Use Set over Array for subscriber/listener collections — O(1) add/remove vs O(n) splice.
  • Use WeakMap/WeakRef for caches that should be garbage-collectible. Never use Map to cache objects that have a lifecycle.
typescript
// WRONG — allocates on every notification
function notify(listeners: Array<() => void>): void {
  const snapshot = [...listeners]; // new array every call
  snapshot.forEach((fn) => fn());
}

// CORRECT — iterate Set in place (safe during iteration per spec)
function notify(listeners: Set<() => void>): void {
  listeners.forEach((fn) => fn());
}

Data Structure Choices

  • Bitwise flags over object-based state machines in high-frequency code (dependency tracking, dirty flags).
  • Doubly-linked lists for ordered collections that need O(1) insertion/removal (e.g., dependency links).
  • Sentinel nodes to eliminate boundary-check branching in linked lists.
typescript
// WRONG — object state machine in hot path
const state = { isDirty: false, isMaybeDirty: false, isClean: true };

// CORRECT — bitwise flags (zero allocation, branchless checks)
const CLEAN = 0;
const MAYBE_DIRTY = 1;
const DIRTY = 2;
let flags = CLEAN;

Closure Hygiene

  • Avoid closures that capture more scope than needed. Extract hot-path callbacks to module-level functions or methods.
  • Document performance-sensitive code with // PERF: comments explaining the optimization.
typescript
// PERF: Module-level function avoids per-call closure allocation
function notifyListener(listener: Listener): void {
  listener.callback(listener.value);
}

Advanced Type Patterns

These patterns improve type safety, inference quality, and developer experience (DX).

satisfies for Object Literal Validation

Use satisfies to validate object literals against a type without widening. This preserves literal types while ensuring structural compatibility:

typescript
// WRONG — `as` silences errors and widens
const options = { mode: 'strict' } as Options;

// WRONG — annotation widens literal to string
const options: Options = { mode: 'strict' };

// CORRECT — validates AND preserves literal 'strict'
const options = { mode: 'strict' } satisfies Options;

NoInfer<T> for Inference Control

Use NoInfer<T> (TypeScript 5.4+) to prevent a parameter from driving type inference in generic functions. This is critical for public APIs where the wrong parameter could widen the inferred type:

typescript
// Without NoInfer, `fallback` drives inference and widens T to string
function getOrDefault<T>(signal: Signal<T>, fallback: T): T;

// CORRECT — only `signal` drives inference
function getOrDefault<T>(signal: Signal<T>, fallback: NoInfer<T>): T;

const Type Parameters

Use const type parameters in factory functions to preserve literal types in the return value:

typescript
// CORRECT — preserves { count: 0 } not { count: number }
function createStore<const T extends Record<string, unknown>>(initial: T): Store<T>;

Branded Types for Domain Identifiers

Use branded (opaque) types for identifiers that should not be interchangeable. Zero runtime cost:

typescript
type ScopeId = string & { readonly __brand: 'ScopeId' };
type AtomKey = string & { readonly __brand: 'AtomKey' };

// Prevents accidental mixing
function lookupScope(id: ScopeId): Scope; // won't accept AtomKey

Generic Constraint Rules

  • Constrain generics as narrowly as possible: <T extends Record<string, unknown>> not <T>.
  • Prefer interface extending over intersection (&) for object types — better error messages, better hover display, and more efficient type checking.
  • Use readonly tuples for fixed-length arrays: readonly [string, number].
  • Avoid deeply nested conditional types — extract to named helper types for readability.
  • Document type-level invariants with @typeParam and @remarks.
typescript
// WRONG — overly broad generic
function merge<T>(a: T, b: Partial<T>): T;

// CORRECT — constrained to object types
function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T;

// WRONG — intersection (harder to read, worse errors)
type StoreWithActions = Store & Actions;

// CORRECT — interface extending (better DX)
interface StoreWithActions extends Store, Actions {}

Type Assertion Discipline

Type assertions (as) bypass the compiler's safety checks. Every use is a potential unsoundness vector.

Rules

  1. as assertions are a last resort. Every use must have a // SAFETY: comment explaining why it is sound.
  2. as unknown as T double-assertions require especially strong justification — they completely bypass the type system.
  3. Non-null assertions (!) are forbidden. Use explicit null checks, optional chaining (?.), or nullish coalescing (??) instead.
  4. @ts-expect-error is only allowed in test files for negative type tests and must include a description comment: // @ts-expect-error — testing invalid input.
  5. Never silence the compiler to "make it work." If the types don't fit, fix the types — don't cast around them.
typescript
// WRONG — bare assertion
const value = map.get(key) as Value;

// CORRECT — explicit check
const value = map.get(key);
if (value === undefined) {
  throw new Error(`Missing key: ${key}`);
}

// CORRECT — justified assertion with SAFETY comment
// SAFETY: WeakMap.get() returns T | undefined, but we just called .set(key, value)
// in the line above, so this is guaranteed to be defined.
const value = map.get(key) as Value;
typescript
// WRONG — non-null assertion
const element = document.getElementById('app')!;

// CORRECT — explicit null check
const element = document.getElementById('app');
if (!element) {
  throw new Error('Missing #app element');
}

Testing Standards

See Testing Guidelines for the full testing standards, including coverage targets, test patterns, running tests, coverage reports, and debugging.


Code Review Checklist

Every code change (human or AI-authored) must pass this checklist:

Correctness

  • [ ] Logic is correct and handles all edge cases
  • [ ] No unintended side effects
  • [ ] Error conditions are handled gracefully
  • [ ] Types are correct and inference works as expected

Standards Compliance

  • [ ] No enum, namespace, or other forbidden constructs
  • [ ] import type used for type-only imports
  • [ ] Named exports only (no default exports)
  • [ ] Readonly types used where appropriate
  • [ ] No any — uses unknown with type narrowing

Testing

  • [ ] New code has corresponding tests
  • [ ] Tests cover happy path, edge cases, and error conditions
  • [ ] Coverage thresholds met (95%+ statements, branches, functions, lines)
  • [ ] All existing tests pass

Type Quality

  • [ ] No as assertions without // SAFETY: comment
  • [ ] No non-null assertions (!) — use explicit checks
  • [ ] Generic constraints are as narrow as possible
  • [ ] satisfies used instead of as for object literal validation
  • [ ] Type inference quality checked (hover types are readable in IDE)
  • [ ] @ts-expect-error only in test files with description

Performance

  • [ ] No unnecessary allocations in hot paths (no new Object/Array/Map in signal reads)
  • [ ] No memory leaks (subscriptions cleaned up, WeakMap used where appropriate)
  • [ ] Subscriber/listener cleanup verified in all code paths
  • [ ] Bundle size impact is acceptable

Documentation

  • [ ] All exported functions have JSDoc with summary, @param, @returns, @example
  • [ ] All exported interfaces/types have JSDoc with summary and @typeParam
  • [ ] Internal functions have at least a one-line JSDoc summary
  • [ ] Public API changes documented in the package README in docs/api/
  • [ ] Package documentation created/updated alongside code changes
  • [ ] Complex logic has a "why" comment
  • [ ] TypeScript types serve as documentation (descriptive names)