Skip to content

Instantly share code, notes, and snippets.

@gvergnaud
Created April 19, 2026 10:48
Show Gist options
  • Select an option

  • Save gvergnaud/4fde245965b685068338e326c05c8f99 to your computer and use it in GitHub Desktop.

Select an option

Save gvergnaud/4fde245965b685068338e326c05c8f99 to your computer and use it in GitHub Desktop.
TypeScript Code Quality Skill (wip)
name typescript-code-quality
description Use when designing or refactoring code for maintainability, clarity, and strong module boundaries. Emphasize local reasoning, explicit dependencies, separation of side effects from pure logic, separation of control flow from data flow, pragmatic functional patterns over stateful Object Oriented design, and stable module APIs.

Code Quality Architecture

Apply this skill when writing or reviewing code that should be easy to reason about, easy to change, and easy to test.

Core Principle: Local Reasoning

Local reasoning is the ability to understand what a piece of code does by reading only that piece of code and its immediate inputs/outputs, without tracing through the rest of the codebase.

When you can reason locally, you can change code with confidence. When you can't, every change is a gamble.

A function is easy to reason about locally when:

  • Its behavior is fully determined by its arguments
  • Everything it produces comes through its return value
  • Its type signature is its complete contract (no hidden side-contracts like "must call init() first" or "also updates the cache")

A function is hard to reason about locally when it has implicit dependencies: things it depends on or affects that aren't visible in its signature. Every implicit dependency is a trap for the next person who changes the code. They read the signature, believe they understand the contract, make a reasonable change, and break something because the real contract was wider than what was declared.

Common sources of broken local reasoning

  1. Shared mutable state. A function reads or writes a variable that other functions also read or write. You can't know what the function will do without knowing the current state, and you can't know what it will break without finding every other reader/writer. This includes global variables, singleton caches, mutable class fields accessed by multiple methods, and module-level state.

  2. Action at a distance. Event emitters, pub/sub, observer patterns, middleware chains. A function emits an event; some handler somewhere reacts. The emitter's signature says nothing about the consequences.

  3. Implicit ordering constraints. Code that only works if you call init() before process(), or set this.config before calling this.run(). The type system doesn't enforce the order, so correctness lives in the caller's head, not in the code.

  4. Non-local control flow. Exceptions thrown deep in a call stack and caught somewhere far above. The intermediate layers don't declare that they can fail this way. You can't tell from reading a function whether it might throw or what kind of error.

  5. Ambient authority. Functions that reach into the environment: reading env vars, checking the clock, hitting the network, accessing the filesystem. Their behavior changes depending on the world outside the process.

  6. Type lies. When a parameter is string but actually must be a specific format, or any/unknown used to sidestep real contracts. The type signature says one thing; the runtime behavior depends on something else.

Making dependencies explicit

The distinction:

  • Explicit dependency: appears in the function signature (parameters, return type). Visible, compiler-checked.
  • Implicit dependency: exists but invisible at the call site. You must read the implementation (or other files) to discover it.

Making dependencies explicit is not about being verbose. It's about making the code honest.

// Explicit: you see exactly what it needs
function getUser(userId: string, db: Database): User;

// Implicit: hides four dependencies behind zero parameters
class UserService {
  getUser(): User; // internally uses this.db, this.cache, this.logger, this.metrics
}

Blast radius of a change

When you change a function that has only explicit dependencies and no side effects, the blast radius is exactly the set of its callers, which is trivially findable. When you change a function that mutates shared state, emits events, or relies on implicit ordering, the blast radius is unknowable without whole-program analysis.

If a function's entire contract is its type signature, then any change that preserves the signature preserves correctness for all callers. That's a strong, mechanically verifiable guarantee.

Principle: Separate side effects from pure logic

Push I/O, network calls, filesystem access, DB writes, logging, and framework integration to the edges.

Prefer:

  • Pure functions for parsing, validation, mapping, planning, normalization, and decision-making
  • Thin effectful wrappers that call pure logic and then perform the side effect

Good shape:

  1. Read inputs
  2. Build a pure plan
  3. Execute the plan

Avoid:

  • Mixing fetch/write/log calls into transformation logic
  • Hidden mutation inside utility functions
  • Business logic embedded in framework handlers

Principle: Separate control flow from data flow

Keep orchestration code distinct from transformation code.

Prefer:

  • Orchestrators that answer: "In what order do steps happen?"
  • Pure helpers that answer: "How is data transformed?"
  • Data structures that make decisions explicit

Good shape:

  • buildPlan(input) -> Plan
  • partitionPlan(plan) -> { ... }
  • executePlan(plan) -> Result

Avoid:

  • Branch-heavy functions that both decide and mutate
  • Recomputing the same derived state in multiple places
  • Encoding important logic implicitly in string manipulation or ad hoc conditionals

Principle: Prefer pragmatic functional patterns over stateful OO

Default to functions and data structures unless objects clearly reduce complexity.

Prefer:

  • Plain functions
  • Immutable data flow
  • Tagged unions / discriminated unions
  • Explicit inputs and outputs
  • Small composable helpers
  • No shared or global mutation

Use objects/classes only when they model real lifecycle or stateful resources cleanly.

Avoid:

  • Classes that mostly act as namespaces
  • Stateful helpers with hidden internal behavior
  • Methods that depend on initialization order or mutation history
  • for loops that accumulate into arrays or objects via push or reassignment when map, filter, flatMap, reduce, or object spread would express the transformation more directly

Principle: Build modules with clear, stable APIs

Each module should have one job and expose a small surface area.

Prefer modules like:

  • planner: derive normalized plans from raw inputs
  • executor: perform effectful work from plans
  • formatter: transform data for presentation
  • repository: read/write external systems

Module boundaries should make it obvious:

  • what goes in
  • what comes out
  • what side effects happen
  • what invariants are guaranteed

Avoid:

  • "utils" modules with mixed responsibilities
  • leaking transport/framework types across layers
  • requiring callers to understand internal representation details

Practical Heuristics

Use explicit intermediate data

When the system has multiple execution modes, represent them directly.

Prefer:

type ToolPlan =
  | { kind: "mcp"; ... }
  | { kind: "legacy"; ... };

Prefer collection transforms over local accumulation

When transforming one collection into another, prefer declarative operators over manual accumulation.

Prefer:

const activeNames = users
  .filter((user) => user.isActive)
  .map((user) => user.name);

const allCommands = groups.flatMap((group) => group.commands);

Avoid:

const activeNames: string[] = [];
for (const user of users) {
  if (user.isActive) activeNames.push(user.name);
}

The declarative version makes the intent obvious: filter, then map. The mutating version makes the reader track control flow and intermediate state. That cost matters even when the mutation is technically local.

Use local mutation only when there is a clear payoff, such as a measured performance need, an algorithm that is genuinely clearer imperatively, or an API that is inherently mutating. In those cases, isolate the mutation tightly and do not let it leak into surrounding design.

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