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,prettierconfig,tsconfig.jsonstrictness) 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:
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 genuinelyIterable, but isn't aMap), 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:
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/awaitover explicitPromiseconstruction. Preferawaiting overnew 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). readonlyby 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 constobject pluskeyof 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. satisfiesover a type annotation oraswhen 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
noUncheckedIndexedAccessintsconfig.json, which makes array/object index access returnT | undefinedinstead ofT. 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.