Goal: readable, maintainable, robust, testable code using strategic functional programming in TypeScript/JavaScript, with clean-code and clean-architecture principles baked in.
- Code must be boring to read: clear and explicit avoiding overly “clever”.
- Prefer functional programming patterns over OOP: pure functions, immutability, explicit inputs/outputs.
- Architecture: functional core, imperative shell. Core modules are pure and testable; IO shells are thin wrappers around the core.
- Clean code: small functions, single responsibility, no duplication, meaningful names, and straightforward control flow.
-
Organize by feature/domain (e.g.,
user/,billing/,files/), not by technical layer only. -
Inside each feature, prefer a simple split:
domain.ts: types, discriminated unions, invariants.logic.ts: pure business logic (no IO).io.ts/adapter.ts: HTTP, DB, filesystem, Windmill, etc. (“imperative shell”).
- Dependencies flow inward toward domain logic (clean architecture rule).
- Core/domain code does not depend on frameworks, drivers, DB clients, UI, or Windmill APIs.
- IO/adapters depend on the domain, not the other way around.
- Domain logic uses:
- Pure functions.
- Domain types (unions, interfaces).
- Result/Option for failure, never exceptions.
- Default to first-class arrow functions:
const fn = (...) => { ... }. - Prefer “data in, data out”:
(input) => outputfor pure transforms.(deps) => (input) => outputwhere partial application is actually reused (e.g., logger, config, repository).
- Avoid “currying for sport”; if a function is only ever called with all arguments at once and never reused partially, use a simple uncurried signature.
- Prefer explicit pipelines over nested calls:
export const pipe =
<T>(value: T, ...fns: Array<(a: T) => T>): T =>
fns.reduce((acc, fn) => fn(acc), value);
- Use this instead of adding a whole FP framework when only the simple pipe is needed
- For 3+ sequential transforms, default to a pipeline (sync or async) rather than deeply nested expressions.
- Asynchronous pipelines:
- Prefer
async/awaitwith a linear flow. - For collections, use
Promise.all+mapfor concurrent work where appropriate.
- Prefer
- No nested conditionals in domain/business logic. Instead:
- Use guard-style early returns:
const process = (x: X): Y => {
if (!x.enabled) return defaultY;
if (!x.valid) return fallbackY;
return computeY(x);
};
- Use lookup tables / maps for simple tag-based branching:
const handlers: Record<State, (v: Value) => Value> = { ... };
const handle = (state: State, v: Value) => handlers[state](v);
- Only allow nesting when the logic truly forms independent, simple “guard blocks”, and there is no clearer alternative.
- ts-pattern is approved when it clearly improves clarity and safety:
- Complex discriminated unions.
- Multiple fields and nested shapes to match.
- When exhaustiveness checking helps prevent bugs.
- Avoid using ts-pattern for trivial one-branch conditionals you could express with a simple
ifor map.
- Use
type/interface+ discriminated unions; avoid class hierarchies. - Replace boolean flag soups with unions:
type Payment =
| { kind: 'Pending'; id: string }
| { kind: 'Succeeded'; id: string; receiptUrl: string }
| { kind: 'Failed'; id: string; reason: string };
- Use readonly where it communicates intent and prevents accidental changes:
ReadonlyArray<T>,Readonly<T>,as constfor config and static data.
- Try to “make invalid states unrepresentable” without exploding type complexity; use types to enforce invariants where it pays off.
- App/domain code does not throw. Failure is represented as data:
Result<T, E>/Option<T>(via neverthrow).- Domain functions return
Result/Optioninstead of throwing exceptions.npmjs+2
- Exceptions are only allowed:
- In thin framework-required integration points (e.g., library callback that must throw).
- Where the platform forces it, and even then prefer mapping into a
Resultas early as possible.
- With Result in place (neverthrow), treat flows as railways:
- Success rail vs error rail, composed with
map,mapErr,andThen, etc. - Write linear, compositional chains instead of nested
if/elseor try/catch.
- Success rail vs error rail, composed with
- Errors are only handled or surfaced at edges:
- HTTP handlers, CLIs, Windmill steps, background workers.
- At the edge, convert
Resultto status codes/logs/metrics.
- Use
async/awaitto keep call paths flat:- No nested
.thenchains and no “Promise pyramid” patterns.
- No nested
- For multiple async operations:
- Use
Promise.all+mapfor parallel IO when safe. - Use simple
for...ofwithawaitwhen order or back-pressure matters.
- Use
- Combine with Result using helpers (e.g.,
andThenAsync), so async flows remain linear and readable.
Global rule: inline generic helper first; add a dependency only when it materially improves readability, safety, or maintainability.
-
neverthrow
- Standard for Result/Option; default for error handling instead of
throw.
- Standard for Result/Option; default for error handling instead of
-
date-fns
- For non-trivial date/time math and formatting; do not roll your own date logic.
-
ts-pattern
- For complex pattern matching and exhaustive union handling, when it clearly improves code.
-
Remeda
- Utility library for transformations when an inline
pipeand the built-ins (map,filter,reduce) are not enough and the data plumbing becomes noisy.
- Utility library for transformations when an inline
-
Kysely
- Type-safe SQL query builder; only for sufficiently complex queries (joins, composable filters, reusable builders).
- No ORMs. No ActiveRecord-style or heavy relational mappers. Use SQL (with or without Kysely).
- No heavy FP frameworks (e.g., fp-ts) as a default in app code; if used at all, they must be isolated behind simple interfaces.
- No DI containers; instead, pass dependencies via parameters or simple factories.
For any “approved” dependency:
- Ask “Would this be significantly harder to read, reason about, or maintain without this dependency?”
- If the honest answer is “no”, implement locally:
- Inline
pipe. - Small mapping helper.
- Plain SQL query.
- Inline
- Do not implement:
- Inheritance and complex class hierarchies.
- Mutable instance fields with non-obvious lifecycles.
- Service singletons with shared mutable state.
- DI frameworks and OOP patterns like “Repository” as a class with methods and hidden state.
- When classes are forced by a framework/SDK:
- Keep them thin and declarative.
- Delegate to pure functions in the core; treat classes as adapters at the edge.
- Small, focused functions: each does one thing, with a clear name and clear inputs/outputs.
- No duplication of domain rules; extract helpers when the same idea appears in multiple places.
- Naming:
- Functions named after what they return or do in domain terms (
calculateInvoice,enrichFrontmatter,resolveAuth). - Avoid abbreviations and “generic” names like
handleData,processInfo.
- Functions named after what they return or do in domain terms (
- Comments:
- Use comments to explain why, not what (the code should show “what”).
- Avoid dead comments and keep them updated or delete them.
- Testing:
- Favor many small tests over a few large ones.
- Pure functions should be trivial to test (no globals, no IO).
- IO-bound shells get integration-style tests with realistic inputs.
- Test runner preference
- Use bun test as the default runner (fast, built-in, TS-friendly).
- Fall back to Vitest only when bun’s runner is missing a needed capability.
- What to test where
- Functional core (pure logic):
- Default to example-based unit tests over small, focused functions.
- Use property-based tests (e.g., fast-check) for critical, subtle, or edge-case-heavy logic (parsers, transforms, pricing, date math, state machines).
- Imperative shell (IO):
- Prefer small integration tests (e.g., hitting test DB, HTTP, filesystem) over heavy mocking.
- Keep these shells thin so integration tests stay simple.
- Functional core (pure logic):
- Mocks and fakes
- Avoid mocks by default.
- Use contract-style tests instead:
- Define clear interfaces/ports (e.g.,
FileStore,UserRepo,HttpClient). - Provide simple in-memory fakes and real implementations.
- Run a shared test suite against both to ensure they obey the same contract.
- Define clear interfaces/ports (e.g.,
- Property-based testing
- Use fast-check (or similar) selectively when:
- You have a well-defined invariant over a large input space.
- Bugs often come from weird combinations or boundary conditions.
- Don’t use property tests for trivial plumbing; keep them focused on high-value invariants.
- Use fast-check (or similar) selectively when:
- Pact / contract testing between services
- For important service boundaries, use contract testing (e.g., Pact/Pactflow) to define and verify API contracts.
- Use the same schemas/contracts to guide fast-check generators for request/response bodies where it adds value.
- General testing principles
- Prefer many small, fast tests over a few giant ones.
- Tests should read like documentation of behavior and invariants.
- Keep tests runtime-agnostic where possible so they can run under Bun, Node, or Deno with minimal changes.
- When writing new code:
- Start with domain types and pure functions.
- Add thin IO wrappers around them.
- Use Result (neverthrow) instead of
throw. - Reach for ts-pattern, Remeda, date-fns, and Kysely only when they clearly beat inline code.
- When refactoring:
- Pull logic out of IO shells into pure functions.
- Replace nested conditionals with guards, maps, or pattern matching.
- Replace throws with Result/Option.
- Remove unused or unjustified dependencies.
This memo is intentionally concise but complete enough to guide everyday decisions about how to structure and write TypeScript/JavaScript in a functional, clean, and architecturally sound way.