fp-kit-ts
v0.2.0
Published
A small, dependency-free functional programming toolkit for TypeScript — Result, Option, pattern matching, and pipe, with Rust-like ergonomics.
Maintainers
Readme
fp-kit-ts
A small, dependency-free functional programming toolkit for TypeScript. Rust-like ergonomics — methods live on the value, so chains read top-to-bottom — without pulling in a full runtime like Effect-TS.
Status: early. Ships
Result,Option,pipe(), the asyncTask+ boundary helpers, and amatch()pattern matcher.
Why this exists
fp-kit-ts is deliberately opinionated and personal — it's the handful of
functional-programming tools I actually reach for in TypeScript, packaged the way
I like to use them, rather than an attempt to cover everything an FP framework
could. Result/Option for honest error handling, a pipe to compose them, an
async Task for the same at the promise boundary, and just enough pattern
matching to make the unions pleasant to work with. That's the whole scope, on
purpose. If your taste overlaps with mine, great; if you want the full
generality, reach for fp-ts or
Effect.
Install
pnpm add fp-kit-tsResult<T, E>
A value that is either a success (Ok) or a failure (Err).
import { ok, err, Result } from "fp-kit-ts/result";
function parse(input: string): Result<number, string> {
const n = Number(input);
return Number.isNaN(n) ? err("not a number") : ok(n);
}
parse("42")
.map((n) => n * 2)
.unwrapOr(0); // 84
parse("nope")
.mapErr((e) => `parse failed: ${e}`)
.match({
ok: (n) => `got ${n}`,
err: (e) => e, // "parse failed: not a number"
});Data-last combinators
Every method has a curried, data-last twin under the Result namespace, ready
to drop into pipe():
const double = Result.map((n: number) => n * 2);
double(ok(21)); // Ok(42)Option<T>
A value that is either present (Some) or absent (None).
import { some, none, Option } from "fp-kit-ts/option";
Option.fromNullable(process.env.PORT)
.map((p) => Number.parseInt(p, 10))
.filter((p) => p > 0)
.unwrapOr(3000);Bridging Option and Result
some(1).okOr("missing"); // Ok(1)
none.okOr("missing"); // Err("missing")
ok(1).ok(); // Some(1)
err("x").ok(); // Nonepipe
Thread a value left-to-right through unary functions — the readable stand-in for
a native |>. Pairs naturally with the data-last combinators.
import { pipe } from "fp-kit-ts/pipe";
import { Result, ok } from "fp-kit-ts/result";
pipe(
ok<number, string>(2),
Result.map((n) => n + 1),
Result.map((n) => `#${n}`),
Result.unwrapOr("fallback"),
); // "#3"Boundaries & async — Task
Result is the exception-free interior; the boundary is where real throws
and rejecting promises get converted in.
import { fromThrowable } from "fp-kit-ts/result";
import { fromPromise, Task } from "fp-kit-ts/task";
// Sync boundary: a throwing call becomes a Result.
const parsed = fromThrowable(
() => JSON.parse(input),
(e) => `bad json: ${String(e)}`,
); // Result<unknown, string>
// Async boundary: a rejecting promise becomes a Task (an async Result).
const user = await fromPromise(fetchUser(id), (e) => `network: ${String(e)}`)
.map((u) => u.name)
.unwrapOr("anonymous");Task<T, E> wraps Promise<Result<T, E>> and chains with the same vocabulary as
Result (map, mapErr, andThen, orElse, match, unwrap/unwrapOr).
It is awaitable — await task yields a Result<T, E> — and only rejects on a
genuine bug, never on a domain failure (those flow through the Err channel).
Combining several Results / Tasks
Collect a group of results, preserving positional types and unioning the errors:
import { Result, ok, err } from "fp-kit-ts/result";
import { Task } from "fp-kit-ts/task";
Result.all([ok(1), ok("two"), ok(true)]);
// Ok<[number, string, boolean]> — or the first Err (short-circuits)
// Tasks are eager, so these run whatever is already in flight concurrently:
await Task.all([fetchA(), fetchB()]); // all succeed → tuple, else first Err
await Task.any([fetchA(), fetchB()]); // first success wins; all fail → Err(errors[])
await Task.allSettled([fetchA(), fetchB()]); // never fails → array of ResultsTask.allSettled is the one to reach for when partial success is fine (e.g.
query every provider, keep the ones that answered):
const settled = await Task.allSettled(providers.map((p) => p.getRate()));
const rates = settled.unwrap().filter((r) => r.isOk()).map((r) => r.unwrap());Errors widen through the chain
andThen may introduce a new error type — the channel widens to E | F
rather than forcing a single E:
parse(input) // Result<number, ParseError>
.andThen(validate) // Result<number, ParseError | ValidationError>
.andThen(persist); // Result<Saved, ParseError | ValidationError | DbError>TypeScript has no ?/From operator like Rust, so this union-widening is how
heterogeneous errors compose. The exhaustive match at the end then forces you
to handle every variant you accumulated. (orElse likewise widens success to
T | U when recovery produces a different value.)
match
A ts-pattern-flavored matcher. .exhaustive() is a compile error unless every
case is handled; .otherwise(fallback) is the escape hatch. Typed pattern helpers
(Result.Ok, Result.Err, Option.Some, Option.None) mean you never write a
tag string.
import { match, P } from "fp-kit-ts/match";
import { Result } from "fp-kit-ts/result";
const label = match(result)
.with(Result.Ok(0), () => "zero") // payload sub-pattern
.with(Result.Ok(P.when((n) => n > 100)), () => "big") // guard on the payload
.with(Result.Ok(P.select()), (n) => `got ${n}`) // capture the inner value
.with(Result.Err, (e) => `err: ${e.error}`) // e is narrowed to Err<T, E>
.exhaustive();Patterns can be literals (42, "click"), structural objects/tuples
({ type: "click", button: "left" }), tag patterns (Result.Ok,
Result.Ok(pattern)), or P combinators — P._ (wildcard), P.string /
P.number / P.boolean / P.bigint / P.nullish, P.when(guard),
P.union(...), and P.select() / P.select("name").
Two deliberate limits for a lite library:
- Exhaustiveness is sound but conservative. It never wrongly accepts a
non-exhaustive match, but a partial pattern (a payload literal like
Ok(0), or aP.whenguard) does not count as covering its variant — you may need a broader catch-all case. Guards never contribute to exhaustiveness. - Selection typing uses an explicit type argument —
P.select<number>()— rather than deep positional inference; a bareP.select()yieldsunknown.
Recipe — matching a
Result/Optionexhaustively. Match the unwrapped variant union, notResult.Err(pattern). A payload sub-pattern is partial, so it won't satisfy.exhaustive()(see the first limit above). Either use the instance formresult.match({ ok, err }), or match on the bare error union with structural tags:// ✅ exhaustive, each arm narrowed match(error) // error is a discriminated union with `_tag` .with({ _tag: "NotFound" }, (e) => 404) .with({ _tag: "RateLimited" }, (e) => 429) .exhaustive(); // ⚠️ match(result).with(Result.Err(P.select()), …) leads into the // conservative-exhaustiveness wall — reach for one of the forms above.
API surface
- Methods:
map,mapErr/filter,andThen,orElse,unwrap,unwrapErr,unwrapOr,unwrapOrElse,isOk/isErr/isSome/isNone(type-guards),match, and theok/err/okOr/okOrElsebridges. - Constructors:
ok,err,some,none,Option.fromNullable. - Data-last:
Result.*/Option.*curried versions of the above. - Composition:
pipe(value, ...fns)(up to 10 functions). - Boundaries:
fromThrowable(sync),fromPromise/fromSafePromise(async). - Async:
Task<T, E>withTask.ok/Task.err/Task.fromResult/Task.fromPromise. - Combining:
Result.all,Task.all/Task.any/Task.allSettled.andThenwidens the error channel toE | F;orElsewidens success toT | U. - Matching:
match(value).with(pattern, handler).exhaustive()/.otherwise(fallback), with tag patternsResult.Ok/Result.Err/Option.Some/Option.Noneand thePcombinators (P._,P.string,P.when,P.union,P.select, ...).
Performance
The type layer is free — overloads, exhaustiveness checking, and the pattern type machinery are erased at compile time and emit no code. The runtime cost is the usual "immutable wrappers allocate" tradeoff, and for ordinary (I/O-bound) application code it is negligible. Rough guide, cheapest first:
Result/Option— each constructor and each.map()/.andThen()allocates one small object (they're immutable); calls are monomorphic andnoneis a shared singleton. On the error path this is typically faster thanthrow/catch. The instanceresult.match({ ok, err })is essentially free (a branch + a call).pipe— one rest-args array plus areduce; the data-last combinators (Result.map(fn)) allocate a small closure when constructed. Trivial.Task— one Promise + wrapper per step, so you pay microtask scheduling per chain link. Expected, since you're already async.match()builder — the heaviest: each.with()allocates a matcher and copies the case list (O(k²) in the number of clauses), and resolution walks patterns structurally. This is because JavaScript has no native pattern matching — we emulate it at runtime, unlike Rust/OCaml/Elixir wherematchis a compiler/VM primitive compiled to a jump table.
In a hot loop (millions of iterations), prefer plain values, and for dispatch
use the instance result.match({ ok, err }) or a raw switch (value) over the
match() builder. Everywhere else, reach for whatever reads clearest.
Bundle size: sideEffects: false plus subpath exports let bundlers
tree-shake unused pillars — importing fp-kit-ts/result won't pull in the matcher.
License
MIT
