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: truenoUncheckedIndexedAccess: truenoImplicitOverride: truenoPropertyAccessFromIndexSignature: trueverbatimModuleSyntax: trueisolatedModules: 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
| Construct | Why | Use Instead |
|---|---|---|
enum | Not in JS spec, generates runtime code, poor tree-shaking | as const objects |
namespace | Legacy TS construct, not in JS spec | ES modules |
declare global | Pollutes global scope | Module-scoped types |
/// <reference> | Legacy directive | import type |
Parameter properties (constructor(private x)) | TS-only syntax | Explicit field declaration |
abstract class | Prefer composition over inheritance | Interfaces + factory functions |
| Decorators (legacy TS) | Use only TC39 Stage 3 decorators if needed | Functions / HOFs |
Required Patterns
Enums → as const objects:
// 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:
// 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
// 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
| Entity | Convention | Example |
|---|---|---|
| Files | kebab-case.ts | create-store.ts |
| Functions | camelCase | createStore() |
| Types/Interfaces | PascalCase | StoreApi<T> |
| Constants | SCREAMING_SNAKE_CASE or camelCase | DEFAULT_OPTIONS, defaultOptions |
| Type parameters | Single uppercase letter or descriptive PascalCase | T, TState |
| Private fields | # prefix (ES private) | #version |
| Boolean variables | is/has/should/can prefix | isDirty, hasSubscribers |
| Factory functions | create prefix | createStore(), createScope() |
| Hook functions (React) | use prefix | useStore(), 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
readonlyon all properties that should not be mutated - Prefer
interfacefor public API shapes,typefor unions and intersections - Use
unknownoverany— 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-anyis set toerror - Don't use
astype assertions unless absolutely necessary (prefer type narrowing) - Don't use non-null assertions (
!) — handle nullability explicitly - Don't use
@ts-ignoreor@ts-expect-errorwithout 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.login 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
/**
* 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
/**
* 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
/**
* Consumer is up-to-date — no recomputation needed.
* @internal
*/
export const CLEAN = 0;JSDoc Rules
| Element | Required Tags |
|---|---|
| Exported function | Summary, @param (all params), @returns, @example (at least 1) |
| Exported interface/type | Summary, @typeParam (if generic), method docs with @param/@returns |
| Exported constant | Summary (one-line), @internal if not public API |
| Internal function | One-line /** summary */ (no @example needed) |
| Internal interface/type | One-line /** summary */ |
JSDoc Style
- Start with a verb: "Create", "Return", "Track", "Check" — not "This function creates..."
- Use
@remarksfor behavioral notes (scheduling, equality semantics, error conditions) - Use
@examplewith triple-backtick TypeScript code blocks - Use
@seeto link to related functions:@see {@link computed}for derived values - Use
@internalfor exports that are not part of the public API - Use
@typeParamfor generic parameters on interfaces and types - Do not use
@throws— document error behavior in@remarks
Error Handling
// 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
// 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 signalget(), computedfn(), or subscriber notification loops. - Reuse objects instead of creating new ones in loops. Pre-allocate and mutate in place when safe.
- Use
SetoverArrayfor subscriber/listener collections — O(1) add/remove vs O(n) splice. - Use
WeakMap/WeakReffor caches that should be garbage-collectible. Never useMapto cache objects that have a lifecycle.
// 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.
// 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.
// 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:
// 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:
// 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:
// 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:
type ScopeId = string & { readonly __brand: 'ScopeId' };
type AtomKey = string & { readonly __brand: 'AtomKey' };
// Prevents accidental mixing
function lookupScope(id: ScopeId): Scope; // won't accept AtomKeyGeneric Constraint Rules
- Constrain generics as narrowly as possible:
<T extends Record<string, unknown>>not<T>. - Prefer
interfaceextending over intersection (&) for object types — better error messages, better hover display, and more efficient type checking. - Use
readonlytuples for fixed-length arrays:readonly [string, number]. - Avoid deeply nested conditional types — extract to named helper types for readability.
- Document type-level invariants with
@typeParamand@remarks.
// 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
asassertions are a last resort. Every use must have a// SAFETY:comment explaining why it is sound.as unknown as Tdouble-assertions require especially strong justification — they completely bypass the type system.- Non-null assertions (
!) are forbidden. Use explicit null checks, optional chaining (?.), or nullish coalescing (??) instead. @ts-expect-erroris only allowed in test files for negative type tests and must include a description comment:// @ts-expect-error — testing invalid input.- Never silence the compiler to "make it work." If the types don't fit, fix the types — don't cast around them.
// 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;// 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 typeused for type-only imports - [ ] Named exports only (no default exports)
- [ ] Readonly types used where appropriate
- [ ] No
any— usesunknownwith 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
asassertions without// SAFETY:comment - [ ] No non-null assertions (
!) — use explicit checks - [ ] Generic constraints are as narrow as possible
- [ ]
satisfiesused instead ofasfor object literal validation - [ ] Type inference quality checked (hover types are readable in IDE)
- [ ]
@ts-expect-erroronly in test files with description
Performance
- [ ] No unnecessary allocations in hot paths (no
new Object/Array/Mapin 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)