Skip to content

Instantly share code, notes, and snippets.

@tomdavidson
Created December 23, 2025 08:53
Show Gist options
  • Select an option

  • Save tomdavidson/de54ff9a935d92d7e4e21a7f4119cbcb to your computer and use it in GitHub Desktop.

Select an option

Save tomdavidson/de54ff9a935d92d7e4e21a7f4119cbcb to your computer and use it in GitHub Desktop.
Code Preferences | TypeScript

Code Preferences for TypeScript

Goal: readable, maintainable, robust, testable code using strategic functional programming in TypeScript/JavaScript, with clean-code and clean-architecture principles baked in.


1. High-level principles

  • 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.

2. Modules and architecture

2.1 Structure

  • 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”).

2.2 Clean architecture alignment

  • 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.

3. Functions and composition

3.1 Function style

  • Default to first-class arrow functions: const fn = (...) => { ... }.
  • Prefer “data in, data out”:
    • (input) => output for pure transforms.
    • (deps) => (input) => output where 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.

3.2 Composition and pipelines

  • 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/await with a linear flow.
    • For collections, use Promise.all + map for concurrent work where appropriate.

4. Control flow rules

4.1 Conditionals

  • 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.

4.2 Pattern matching

  • 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 if or map.

5. Types and data modeling

  • 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 const for config and static data.
  • Try to “make invalid states unrepresentable” without exploding type complexity; use types to enforce invariants where it pays off.

6. Error handling: Result, not exceptions

6.1 Never throw (in app logic)

  • App/domain code does not throw. Failure is represented as data:
    • Result<T, E> / Option<T> (via neverthrow).
    • Domain functions return Result/Option instead 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 Result as early as possible.

6.2 Railway-oriented style

  • 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/else or try/catch.
  • Errors are only handled or surfaced at edges:
    • HTTP handlers, CLIs, Windmill steps, background workers.
    • At the edge, convert Result to status codes/logs/metrics.

7. Asynchrony and promises

  • Use async/await to keep call paths flat:
    • No nested .then chains and no “Promise pyramid” patterns.
  • For multiple async operations:
    • Use Promise.all + map for parallel IO when safe.
    • Use simple for...of with await when order or back-pressure matters.
  • Combine with Result using helpers (e.g., andThenAsync), so async flows remain linear and readable.

8. Libraries and dependencies

Global rule: inline generic helper first; add a dependency only when it materially improves readability, safety, or maintainability.

8.1 Approved libraries (when justified)

  • neverthrow

    • Standard for Result/Option; default for error handling instead of throw.
  • 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 pipe and the built-ins (map, filter, reduce) are not enough and the data plumbing becomes noisy.
  • Kysely

    • Type-safe SQL query builder; only for sufficiently complex queries (joins, composable filters, reusable builders).

8.2 Banned / discouraged

  • 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.

8.3 Justification rule

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.

9. OOP avoidance

  • 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.

10. Clean code practice

  • 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.
  • 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.

12. Testing strategy

  • 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.
  • 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.
  • 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.
  • 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.

12. How to apply this memo

  • 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment