# Typescript Coding Style

> Applies Jack Moore's personal TypeScript/TSX coding style — derived from his own pre-2025 open-source code and gists, not a generic style guide. Use this whenever writing, generating, refactoring, or reviewing TypeScript or TSX code: new functions, modules, types, React components, hooks, API handlers, utility libraries, or any code-generation task in a TS/TSX file. Trigger even when the user doesn't explicitly ask for "my style" — it should shape ordinary TypeScript work by default. Key signals: declarative composition over loops, object-param destructuring instead of positional args, plain factory functions over classes, discriminated unions and exhaustive `match` for state, errors modeled as values (Result/Option) instead of scattered try/catch, erasable syntax (no enums/access modifiers/decorators), fully spelled-out identifiers, and minimal comments. Always defers to a project's existing conventions first (lint config, prevailing patterns, framework requirements like NestJS decorators) — this skill fills

- Skill: `jtmthf/typescript-coding-style` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add jtmthf/typescript-coding-style`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jtmthf/typescript-coding-style/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: Apache-2.0
- Author: jtmthf (https://skillmd.com/u/jtmthf)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jtmthf/typescript-coding-style

---


# TypeScript Coding Style — Jack Moore

This encodes how Jack actually writes TypeScript, reverse-engineered from ~35 of his pre-2025 gists and personal repositories (utility libraries, a Result/Option library called `outcome`, NestJS services, Next.js apps). It is not a generic best-practices list — every rule below has a citation in his own code, and the exceptions are just as load-bearing as the rules.

## Priority: existing conventions win

Before applying anything below, check what the project already does:

- **Lint/format config** (`.eslintrc`, `biome.json`, `prettier` config, `tsconfig.json` strictness) always wins over personal preference.
- **Prevailing patterns already in the file or module you're editing** win. If the surrounding code uses classes, positional args, or try/catch, match it rather than introducing a jarring one-off in Jack's style.
- **Framework requirements are not violations.** NestJS demands `@Injectable()`/`@Controller()` classes and constructor DI; Angular demands classes; a library you're porting or extending may demand inheritance. Follow the framework in the framework-mandated parts (decorators, class shape, DI wiring) — Jack does this too — and apply his style in the parts the framework doesn't dictate: method bodies, DTOs/helpers, business logic, error handling.

Apply this skill's style when: writing new files, filling gaps with no established local convention, or the user asks for "my style" / "your style" / "the way you'd write it."

## Core style

**1. Declarative over imperative.** Prefer composing `map`/`filter`/`flatMap`/`reduce`, generator pipelines, or a `pipe`/`flow` chain over manual index loops and mutation. Use type-guard predicates (`pickBy(obj, isNonNullable)`) instead of hand-rolled null-filtering loops.

**2. Object params over positional args.** Any function taking more than ~2 parameters — and especially any "options" or domain payload — takes a single destructured object parameter, with defaults applied in the destructuring:
```ts
function useEventSource<T = string>(
  url: string,
  { withCredentials, extractor = JSON.parse }: UseEventSourceOptions<T> = {},
) { /* ... */ }
```
An identifying key (an id, a slug) can stay positional alongside a trailing options object — `updateArticle(slug, { title, body })` is fine. The rule is "domain payloads as objects," not "zero positional args ever."

**3. Plain objects and functions over classes — with two real exceptions.** Default to factory functions returning object literals, not classes. Reach for a class only when:
   - modeling a **stateful collection/data structure**, especially one that implements a standard interface (`Iterable<T>`, `AsyncIterable<T>`, `Map<K, V>`) — implement these standard interfaces proactively when it makes the type more portable/interoperable, even if nothing forces it. Don't force-fit an interface that doesn't match the domain just because it's available (e.g. a task queue is genuinely `Iterable`, but isn't a `Map`), or
   - **deliberately porting/mirroring an external API's design** (e.g. a TS port of a Java library keeps that library's class hierarchy on purpose).

   Outside those two cases, a class is a smell. Prefer composition of small functions and plain object state. The same instinct applies one level down: prefer standard platform primitives over bespoke equivalents for cross-cutting concerns — `AbortController`/`AbortSignal` for cancellation, `EventTarget` for pub/sub — rather than inventing a custom cancel-token or emitter shape.

**4. Erasable syntax.** No `enum` (use string-literal unions), no `public`/`protected`/`private` access modifiers, no experimental decorators. When a field genuinely needs encapsulation, use a JS `#private` field, not the TS `private` keyword. This is a habit that solidified in his 2022+ code — weight newer patterns over older ones when they conflict.

**5. Make impossible states unrepresentable.** Model state with discriminated unions and narrow with type guards or an exhaustive `match({ ... })` visitor rather than a "bag of optionals" (a single type with several `?` fields that admits invalid combinations no valid state actually has). Tag the discriminant field `type` (or `value` for state-machine states) — not `kind`. When multiple branches repeat the same properties, don't repeat the literal — factor the shared fields into a named base type and compose per branch with an intersection (`BaseContext & { user: User; error: undefined }`); that per-branch intersection is idiomatic and different from the type-vs-interface call in rule 11. See `references/examples.md` for the canonical `Result`/`Option` and state-machine shapes.

**6. Spell everything out.** No abbreviated identifiers — `previous`/`accumulator`/`predicate`/`producer`, never `prev`/`acc`/`pred`/`fn`. This holds even for loop variables and generic callback params.

**7. Minimal comments; JSDoc/TSDoc when behavior isn't self-evident.** Code should read as self-explanatory from naming and types by default. Reserve comments for genuinely public/exported APIs and for the cases where intent isn't obvious from the signature — be concise, and use the `{@link otherThing}` tag to cross-reference related functions/types in the same file rather than re-explaining them.

**8. Errors as values; try/catch stays narrow and centralized.** Model fallible operations as a `Result<T, E>` — prefer a **named object shape** (`{ ok: true; value: T } | { ok: false; error: E }`) over a positional `[error, value]` tuple; if a tuple is ever genuinely warranted, its positions should still be named via labeled tuple elements. Throwing is fine where it produces a desirable outcome in the surrounding system (e.g. a framework request handler that expects to catch and translate exceptions) — the rule is about avoiding manual `try/catch` at ordinary call sites, not eliminating `throw` everywhere. When a `try/catch` is genuinely needed, wrap it once at that single boundary and convert it into a value immediately — don't let try/catch spread through calling code:
```ts
function tryCatch<TValue>(producer: () => TValue): Result<TValue, unknown> {
  try {
    return { ok: true, value: producer() };
  } catch (error) {
    return { ok: false, error };
  }
}
```
Errors that carry a caught cause should stay real `Error` instances (or a subclass) so stack traces survive — wrap with `new Error(message, { cause: originalError })` rather than discarding the original or re-throwing a plain string/object.

**9. Invariants upfront, not defensive code.** Guard clauses and early returns (or an `invariant()` call) at the top of a function, then a straight-line happy path — not scattered null checks deeper in the body. `unwrap()`/`expect()`-style APIs fail fast on the impossible branch instead of quietly tolerating it. Throw the most specific applicable error — a native subclass (`RangeError`, `TypeError`) or a purpose-built one (`InvariantError`) — rather than a generic `new Error(...)`, so callers and stack traces carry real signal. Model "must always be considered, but may be absent" fields as `field: T | undefined` rather than `field?: T` — the explicit form forces every caller to consciously decide what to pass instead of silently omitting it.

**10. Strict at the boundary, pragmatic inside — but never pragmatic about untrusted data.** Public and domain-facing APIs stay fully typed — discriminated unions, type guards, no `any`. Inside a generic function's *body*, `any` is genuinely the right tool when TypeScript can't line up your runtime logic with your type-level logic (a conditional return type is a common trigger for this) — that's a different situation from casting to shortcut real work. Never use `as` to force a shape onto external or untrusted input (API responses, form data, env vars, JSON.parse output) — validate it with a schema (e.g. `zod`'s `safeParse`) and let the parsed result carry its real inferred type. Prefer `unknown` over `any` for genuinely-unknown external input. Prefer `undefined` for absence in normal code; reserve `null` for wire formats (JSON/GraphQL) where `null` is the actual serialized value.

**11. `type` over `interface` by default.** Reach for `interface` only when you need declaration merging, or when composing several object shapes together — `interface extends` has meaningfully better compiler performance than a multi-way `&` intersection, so prefer it for that specific case. This is distinct from the per-branch discriminated-union pattern in rule 5 (`BaseContext & { ... }`), which is fine as an intersection because it's narrowing one union member, not building up a shared contract.

## Additional conventions

A few more mechanical rules, folded in from direct review feedback and cross-checked against his code — apply these alongside the core style above:

- **Braces always.** Every `if`/`for`/`while`/etc. body gets braces, even a single statement.
- **`async`/`await` over explicit `Promise` construction.** Prefer `await`ing over `new Promise(...)`/`.then()`/`Promise.resolve()`/`Promise.reject()` chains when the code can be written that way.
- **Generic type parameters default to a descriptive `T`-prefixed name** — `TValue`, `TError`, `TKey`, `TContext` — rather than a single bare letter. A bare letter (`T`, `K`, `V`) is only acceptable for a minimal, single-parameter, self-evident utility (`Array<T>`-shaped signatures).
- **`readonly` by default** on object type properties; omit it only when the property is genuinely mutable.
- **Declare explicit return types on top-level exported functions** (skip this for components returning JSX — the return type is always JSX and adds no signal).
- **Enums**: never introduce a new one — use a string-literal union, or an `as const` object plus `keyof typeof`/`(typeof x)[keyof typeof x]` when you need an enum-like value/label mapping. (Numeric enums are worth actively avoiding: they generate a reverse mapping, so a 4-member enum produces 8 object keys — a common source of confusion.) If a project already has enums, the "existing conventions win" priority above still applies — don't rip them out uninvited.
- **`satisfies` over a type annotation or `as`** when you want to check a value against a type while keeping its narrower inferred type (literal values, tuple shapes) intact.
- Be aware some projects enable `noUncheckedIndexedAccess` in `tsconfig.json`, which makes array/object index access return `T | undefined` instead of `T`. Check for it before assuming an indexed read is safe without a guard.

## Further reading

- `references/patterns.md` — naming conventions, exports, generics, function-overload style, file naming, formatting, and testing conventions.
- `references/examples.md` — canonical code snippets for each principle above, pulled from Jack's own repositories, to pattern-match against when generating new code.

