TypeScript Functional Patterns
Write TypeScript around three complementary ideas: transform, result, and match. Remeda makes data flow readable and inference-friendly; neverthrow makes expected failure explicit in a function's type; ts-pattern makes finite state and union decisions exhaustive. Use the idea that solves the local problem, then compose the ideas at boundaries. The goal is a small, honest API surface—not functional-looking syntax everywhere.
Read PATTERNS.md when implementing or reviewing a non-trivial pipeline, error boundary, or discriminated-union decision.
Process
1. Establish and enforce the project baseline
Inspect package.json, lockfiles, TypeScript configuration, scripts, and nearby
code before editing. This skill establishes a project-wide behavioral baseline,
not a package-wide dependency baseline.
- Add
remeda,neverthrow, orts-patternwith the project's package manager only to a package whose source imports it or whose exported type declarations reference it. In a workspace, inspect each package independently and keep dependencies in the package that owns the import or public type. - Remove a direct dependency after confirming that the package has no source or public declaration reference to it. Respect the existing package manager, lockfile, workspace layout, version policy, and dependency section; do not install a second copy or bypass the lockfile.
- Apply the style to new TypeScript code and migrate touched code toward it. Do not preserve an equivalent legacy utility/error/branching style merely because it predates this skill.
- Follow the project's module system, strictness, formatting, naming, runtime, and test conventions around this functional baseline.
Completion criterion: the project status, package ownership of every library reference, relevant compiler settings, and validation commands are known; every direct dependency has a source or public-type owner.
2. Name the problem before choosing the abstraction
Classify each meaningful piece of logic:
- Transform: normalize, select, filter, group, sort, aggregate, or project
data. Use the corresponding Remeda function for every such collection or
record transformation; use its data-first form for one operation and its
data-last form inside a multi-operation
pipe. - Result: an operation can fail in a known, caller-relevant way. Return
Result<T, E>orResultAsync<T, E>and compose it instead of hiding the failure inthrow,null, a sentinel, or an untyped rejected promise. - Match: a finite union, state machine, protocol message, or nested tagged
structure determines behavior. Prefer ts-pattern and finish with
.exhaustive()when the type is intended to enumerate all cases. - Effect: I/O, mutation, logging, time, or framework integration. Keep it at the edge; make the transformation, result composition, and decision logic pure where practical.
The three libraries are project defaults, not a demand to wrap unrelated expressions in library calls. A local omission is valid only when the corresponding construct does not exist. When a Remeda equivalent exists, native collection methods are not an omission: use Remeda instead. When omitting one in a substantial TypeScript area, state the reason in the change summary or code-level design note.
Completion criterion: every non-trivial branch or transformation has a named reason for its chosen representation, and the core logic is separable from effects.
3. Build transformations as typed data flow
Use Remeda's direct data-first call for exactly one operation:
const summary = pick(user, ["id", "name"]);
The single-stage rule is strict: do not write pipe(value, fn(args)) when
fn is the only transformation. Use pipe only when there are at least two
data transformations to read from left to right:
const names = pipe(users, filter(isActive), map(toName));
The only local exceptions are when the function has no usable data-first form, or when a unary function is deliberately being passed to another higher-order API. In those cases, keep the exception local and make the reason apparent.
This applies to native collection methods as well. Replace equivalent calls
such as items.filter(predicate), items.map(transform), items.reduce(step, initial), items.find(predicate), and items.some(predicate) with the
corresponding Remeda function, even when there is only one operation:
const activeUsers = filter(users, isActive);
const firstAdmin = find(users, isAdmin);
Do not use a native method merely because it is a single step or because it is shorter. Keep native collection methods only when Remeda has no equivalent or when a project/runtime constraint is recorded.
For multi-stage work, use Remeda to express a sequence as a left-to-right pipeline:
- Start with the input and use data-last operators for subsequent stages.
- Keep each stage a named, total transformation when the stage has domain meaning; avoid callbacks that both transform and perform unrelated effects.
- Prefer one pipeline over temporary variables or nested array methods when it makes the intermediate shapes easier to follow.
- Use Remeda's lazy pipeline behavior for bounded or short-circuiting work such
as
take,takeWhile, orfirst; do not assume every pipeline is lazy or free of iteration costs. - Use explicit type annotations at a boundary when inference would widen a
domain type or conceal an accidental
unknown; do not annotate every stage by reflex.
If a transformation can fail, keep the failure visible: parse/validate before
the successful pipeline, or make the stage return a Result and compose the
results instead of throwing from a Remeda callback. In a touched module, replace
the old collection transformation style with Remeda where the operation has a
corresponding Remeda primitive.
Completion criterion: the pipeline has a clear input-to-output story, no stage silently swallows invalid data, and its complexity is lower than the equivalent nested or mutation-heavy code. Every one-operation Remeda use is a direct data-first call unless an explicit exception applies.
4. Model and compose recoverable failures
Use neverthrow at boundaries such as parsing, validation, filesystem/database
access, HTTP calls, and domain commands. Expected failure in a touched module
must be represented as Result/ResultAsync, even when the old code used
exceptions, nullable values, or an untyped rejected promise.
- Define an error union or discriminated error type that carries actionable
context. Avoid
Result<T, Error>when callers need to distinguish cases. - Convert foreign failures at the boundary with
ResultAsync.fromPromiseor an equivalent constructor and map the caught value to the domain error type. - Use
mapfor a successful value transformation,mapErrfor error normalization, andandThenfor the next operation that can fail. - Use
orElseonly for an intentional recovery or fallback; do not use it to erase an error that should be propagated. - Handle a result at the application/effect boundary with
match, or useisOk/isErrwhen control flow genuinely needs a local guard. Prefer a single terminal handling point over repeated unwrapping. - Keep
ResultAsyncasynchronous until the boundary. Do not wrap a promise in several layers ofResultAsync, and do not mixawait/try/catchwith result composition without a clear foreign-effect boundary. - Unwrap only when failure is impossible by construction and that invariant is local, checked, and worth enforcing. Otherwise return the result to the caller.
Use exceptions for programmer errors, violated invariants, cancellation or
failures that the surrounding framework explicitly models as exceptions. The
criterion is recoverability and ownership, not a blanket ban on throw.
Completion criterion: every expected failure has a typed owner, each error is translated exactly once at the appropriate boundary, and callers can observe or recover without inspecting exception strings.
5. Make domain decisions exhaustive
Represent finite states and variants as discriminated unions with stable literal tags. Use ts-pattern when a decision has multiple structural cases, nested variants, guards, or more than one consumer:
- Match on the smallest domain value that owns the decision.
- Put more specific patterns before broad patterns because the first matching case wins.
- Use
P.selector a handler parameter to extract values rather than repeating casts or property checks. - Use
.returnType<T>()when the output contract matters and inference alone is not sufficiently visible. - Finish with
.exhaustive()for closed unions. Use.otherwise()only when an actual domain default exists; do not use it to bypass a missing case. - Use
whenfor a predicate that is truly part of the branch condition, while keeping the predicate named and testable if it carries domain meaning.
For a single boolean or a trivial two-way check, an if may remain clearer, but
all finite domain unions in touched TypeScript code must use ts-pattern or have
an explicit design note explaining the incompatibility. The value of ts-pattern
is compile-time completeness and structural clarity, not decorative syntax.
Completion criterion: adding a new union variant would produce a useful compile failure at every closed decision, and every branch returns the same intentional output type.
6. Compose the three layers at boundaries
Use this default flow when it fits the use case:
- Convert an external input into a typed domain value, returning a
Resulton validation or parsing failure. - Transform valid collections and records with Remeda pipelines.
- Chain fallible operations with
mapandandThen; normalize errors withmapErrat ownership boundaries. - Use ts-pattern to turn the resulting state or domain union into a response, command, view model, or effect.
- Terminally
matchtheResultat the caller/effect boundary.
Do not force all five stages into one expression. Split at a meaningful domain boundary when names, tests, or ownership become clearer.
Completion criterion: data transformations, fallible effects, domain decisions, and terminal effects are visibly distinct, yet the call path remains easy to trace from input to outcome.
Review gate
Before declaring TypeScript work complete, check:
- Is the function's failure vocabulary visible in its return type?
- Are union decisions exhaustive where the domain is closed?
- Are collection transformations readable as data flow rather than callback nesting or mutation?
- Do all collection transformations use Remeda when an equivalent exists,
including single-step
filter/map/find/reduce/somecalls? - Does every one-operation Remeda transformation avoid a one-stage
pipe? - Are side effects at the edge and translated into domain-level results once?
- Did any helper exist only to satisfy a library style, or did it earn its interface through domain meaning, reuse, or a test seam?
- Do the project's formatter, type checker, linter, and focused tests pass?
If a library's current API or type behavior is uncertain, consult its official documentation before inventing a workaround. Treat compiler feedback as part of the design: fix widened unions, unhandled variants, and mixed error types at their source rather than silencing them with casts.