npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tsfpp/prelude

v2.4.0

Published

Functional prelude for TypeScript with Option, Result, and core combinators

Readme

@tsfpp/prelude

A small, strongly-typed functional prelude for TypeScript.

It provides:

  • Algebraic data types: Option and Result
  • Functional combinators: pipe, flow, comp, complement, map, flatMap, tap, traverse*, and more
  • Lightweight immutable List utilities
  • Immutable Map/Set constructor and update helpers
  • A dependency-injected logger port for effectful boundaries

Zero runtime dependencies. Data-last curried API. Designed to compose with pipe.

Installation

pnpm add @tsfpp/prelude

Quick start

import { ok, err, map, flatMap, type Result } from '@tsfpp/prelude';

const parseNumber = (input: string): Result<number, string> => {
  const parsed = Number(input);
  return Number.isNaN(parsed) ? err('not a number') : ok(parsed);
};

const toEven = (n: number): Result<number, string> =>
  n % 2 === 0 ? ok(n) : err('not even');

const result = flatMap(toEven)(map((n: number) => n + 2)(parseNumber('40')));

Core exports

From @tsfpp/prelude:

  • Function combinators: pipe, flow, comp, complement
  • Exhaustiveness: absurd
  • Option: some, none, isSome, isNone, mapOption, flatMapOption, orElseOption, getOrElseOption, matchOption
  • Unit: unit, Unit
  • Result: ok, err, isOk, isErr, map, flatMap, flatMapAsync, mapErr, tryCatch, tryCatchAsync, tap, tapErr, match, getOrElse
  • Logger port: LogLevel, LogEntry, Logger
  • Conversions and guards: fromNullable, isDefined, toNullable, isRecord, fromUnknownString, fromUnknownArray, fromUnknownArrayOf, fromNonEmptyString, getTypedField, getStringField, getNumberField, getBooleanField, findO
  • Branded types: Brand, Every, Any, mkEvery, mkAny
  • Refined numerics: Int, Positive, NonNegative, mkInt, mkPositive, mkNonNegative, isFiniteNumber
  • Non-empty arrays: NonEmptyReadonlyArray, isNonEmptyArray, mkNonEmpty, consNonEmpty, singletonNonEmpty, headNonEmpty, lastNonEmpty, tailNonEmpty, toArrayNonEmpty, lengthNonEmpty, mapNonEmpty, appendNonEmpty, prependNonEmpty, concatNonEmpty, reverseNonEmpty, sortNonEmpty, reduceNonEmpty, reduceMapNonEmpty, traverseNonEmpty
  • Combining (Semigroup/Monoid): Semigroup, Monoid, mkSemigroup, mkMonoid, monoidSum, monoidProduct, monoidString, monoidEvery, monoidAny, monoidArray, monoidRecord, semigroupFirst/semigroupLast, semigroupMax/semigroupMin, semigroupNonEmpty, concatAll, concatAllWith, foldMap, dual
  • Typed record helpers: keysOf, valuesOf, entriesOfRecord, mapValues
  • Equality & ordering: Eq, Ord, Ordering, mkEq, mkOrd, eqStrict, eqStructural, structuralEquals, eqBy, ordBy, reverseOrd, ordThen, eqNumber/eqString/eqBoolean, ordNumber/ordString/ordBoolean, eqArray, eqOption, elemWith, uniqueWith, sortWith, maxWith, minWith, lookupWith
  • Validation (error-accumulating): Validation, valid, invalid, invalidAll, isValid, isInvalid, mapValidation, mapErrorsValidation, apValidation, matchValidation, traverseArrayValidation, sequenceArrayValidation, sequenceStructValidation, validationToResult, resultToValidation
  • Collection helpers: traverseArray, traverseArrayOption, sequenceArrayOption, unique, intoMap, entriesOf, toObject, assoc, dissoc, lookup, intoSet, conj, disj, member
  • Immutable list ADT: List, nil, cons, singletonList, fromArray, toArray, headList, tailList, isNil, isCons, isEmptyList, lengthList, mapList, flatMapList, appendList, reverseList, filterList, foldList, foldLeftList, foldLeftListCurried, traverseList

Why a prelude when these libraries exist?

@tsfpp/prelude is a minimal, dependency-free package of the functional primitives a strict TypeScript codebase reaches for daily — Option, Result, List, branded types, an exhaustiveness witness, and the combinators to use them. It is small on purpose. Each adjacent library either solves a larger problem or pulls a different API shape.

fp-ts offers a comprehensive algebraic library with type classes (Functor, Monad, Applicative), higher-kinded type encoding, and a wide ADT surface (Option, Either, Task, Reader, State, Lens, ...). It is theoretically rigorous, but its HKT encoding, module-registration ceremony, and learning curve are disproportionate when only Option and Result are needed. Active development has slowed; the original author has redirected effort to Effect.

Effect is not in the same category. It is a full effect runtime: fibers, structured concurrency, dependency injection (Layer / Context), schedules, retries, tracing, and a built-in schema system. Adopting Effect is an architectural commitment that rewrites the shape of every effectful function in the codebase. This prelude takes the opposite stance: effects stay as plain Promise<Result<A, E>> and the I/O boundary stays narrow. Effect is excellent — for a different design point.

neverthrow is the closest neighbour in spirit: small, focused on Result, easy to adopt. Two differences. First, its API is method-chained (r.map(f).andThen(g)) rather than data-last functions composed with pipe. Both styles have merit; this prelude picks the latter. Second, neverthrow has no Option and no List, so the absent-value channel and the recursive-structure channel need to come from elsewhere.

purify-ts is similar in shape to neverthrow but broader: Maybe, Either, EitherAsync, NonEmptyList, and a Codec validation layer. Same method-chain API. The codec layer overlaps with Zod, which most projects already use at boundaries. Good library; the surface diverges from the data-last style this prelude follows.

Remeda solves a different problem entirely: immutable, typed replacements for Lodash utilities (groupBy, partition, pipe, pick, omit). It contains no ADTs. This prelude and Remeda are complementary — Remeda for collection plumbing, the prelude for the algebra. They sit together without overlap.

Zod is a runtime schema validator, not an ADT library. Its role is the boundary: parsing unknown from HTTP, files, or storage into validated domain types. The prelude's fromUnknownString, fromUnknownArray, and isRecord are deliberately minimal — for anything more than trivial, lift the Zod result into Result<A, ZodError> and let the prelude take over. Strictly complementary; both belong in the same codebase.

What this prelude uniquely provides

  • A small surface — only what a strict functional TypeScript codebase reaches for daily.
  • Zero runtime dependencies.
  • Plain discriminated unions with a _tag discriminator rather than class-based ADTs with methods. The ADTs are the data: serializable, structurally typed, no hidden behaviour, no this.
  • Data-last curried combinators that compose with pipe, not method chains.
  • Algebraic laws (identity, composition, monad laws) documented inline as JSDoc, alongside each combinator they govern.
  • A List ADT — none of the adjacent libraries ship one with this exact shape, and most TypeScript codebases reach for arrays where prepend-heavy workloads or structurally recursive processing make a singly-linked list the right tool.
  • Branded types with smart constructors as first-class citizens, not an afterthought.
  • An absurd exhaustiveness witness as a single named export, not coupled to a larger module system.

The prelude is opinionated about style — data-last, _tag discriminants, no methods on ADTs — and the opinion is the point. If those choices align with the codebase you are building, this is a working, dependency-free starting point. If you prefer Effect's runtime or fp-ts's type-class machinery, adopt those; the trade-offs are clearly different.

The conventions encoded here are described more formally in @tsfpp/standard. The prelude does not depend on it and is usable on its own.

Usage idioms

These conventions are how the prelude is meant to be used. Following them keeps your code refactor-safe, readable, and consistent with the algebraic laws documented on each combinator.

Use the type guards, not the _tag field

_tag is an implementation detail. Use the exported guards.

// Yes
if (isOk(result)) {
  console.log(result.value);
}

// No — couples your code to the internal shape
if (result._tag === 'Ok') {
  console.log(result.value);
}

The one place _tag is appropriate is exhaustive pattern matching with absurd, where switching on it is canonical:

const handle = (r: Result<number, string>): string => {
  switch (r._tag) {
    case 'Ok':  return `value: ${r.value}`;
    case 'Err': return `error: ${r.error}`;
    default:    return absurd(r);
  }
};

Compose with combinators, don't unwrap-and-rebuild

If a combinator already exists for what you want to do, use it. Hand-rolling the same logic risks subtle bugs — forgetting to widen Err, swallowing variants, dropping a None channel.

// Yes
const doubled = map((n: number) => n * 2)(result);

// No
const doubled = isOk(result) ? ok(result.value * 2) : result;

Use flatMap for dependent steps, map for independent ones

If the next step might itself fail or be absent, use flatMap (or flatMapOption). If it cannot, use map (or mapOption). Mismatching them produces nested Result<Result<T, E>, E> or Option<Option<T>>, which is almost never what you want.

Naming convention. Result is the base ADT: its combinators are unsuffixed (map, flatMap, getOrElse, match). Combinators specialised to another ADT carry that ADT's full type name as a suffix — mapOption, flatMapOption, getOrElseOption, matchOption, mapList, headNonEmpty. There is no abbreviated (mapO) or single-letter (getOrElseR) form. See Rule 7.8 in the standard.

// map: transformation cannot fail
const upper = map((s: string) => s.toUpperCase())(name);

// flatMap: transformation can fail
const validated = flatMap(validateEmail)(input);

Use orElseOption to keep Option context, getOrElseOption to collapse it

For optional data, use orElseOption when you still want an Option<A> after fallback. Use getOrElseOption when you want a concrete A.

import {
  fromNonEmptyString,
  getOrElseOption,
  orElseOption,
  some,
} from '@tsfpp/prelude';

const parsed = fromNonEmptyString(rawName);

// Option -> Option
const withFallback = orElseOption(() => some('Anonymous'))(parsed);

// Option -> string
const value = getOrElseOption(() => 'Anonymous')(parsed);

Combine with a Monoid when folding a collection

Eq answers "are these the same?", Ord answers "which comes first?", and a Monoid answers "how do two of these combine?". The identity element is what makes folding an empty collection total — no Option, no special case.

import { concatAll, foldMap, monoidSum, monoidAny, mkAny } from '@tsfpp/prelude';

concatAll(monoidSum)([]);                                  // 0 — total, no Option
foldMap(monoidSum)((o: Order) => o.seats)(orders);         // sum of a projection
foldMap(monoidAny)((o: Order) => mkAny(o.overdue))(orders); // "is any overdue?"

monoidEvery and monoidAny are deliberately distinct types: their identities differ (true vs false), so picking the wrong one silently inverts the answer on an empty collection. The Every/Any brands make that unrepresentable.

Keep the non-empty proof across the pipeline

Proving an array is non-empty and then calling .map() on it hands back a plain ReadonlyArray — the proof is discarded on the first transformation, and every later head is back to returning an Option. A refinement that cannot survive a map buys very little, so the operations that cannot empty a collection are typed to preserve it.

import {
  type NonEmptyReadonlyArray,
  mkNonEmpty, mapNonEmpty, sortNonEmpty, reduceNonEmpty,
  headNonEmpty, lengthNonEmpty, ordNumber, matchOption,
} from '@tsfpp/prelude';

// Prove it once, at the boundary.
matchOption<NonEmptyReadonlyArray<number>, string>(
  () => 'no readings',
  (readings) => {
    const celsius = mapNonEmpty(toCelsius)(readings);   // still non-empty
    const hottest = headNonEmpty(sortNonEmpty(ordNumber)(celsius)); // no Option
    const total   = reduceNonEmpty<number>((a, b) => a + b)(celsius);
    return `max ${hottest}, mean ${total / lengthNonEmpty(celsius)}`;
  },
)(mkNonEmpty(rawReadings));

The explicit type arguments on matchOption are load-bearing: A appears only in the Option supplied by the second call, so without them readings infers as unknown. This is true of every data-last eliminator in the prelude.

reduceNonEmpty is the clearest illustration of what the refinement buys: Array.prototype.reduce without an initial value throws on an empty array, so it is partial. Excluding the empty case in the type makes the same operation total — no Option wrapper, no runtime guard.

semigroupNonEmpty() exposes the concatenation as a lawful Semigroup. It is deliberately not a Monoid: the identity would have to be an empty non-empty array, which the type makes unrepresentable. This is the structure Validation uses to accumulate its errors.

Compare non-primitives with an Eq, never ===

=== on any non-primitive is reference equality — { id: 1 } === { id: 1 } is false, and readonly does not change that. So includes, unique, and memo comparisons silently do the wrong thing for records (standard Rule 4.7).

import { eqBy, eqNumber, uniqueWith, elemWith, sortWith, ordBy, ordNumber } from '@tsfpp/prelude';

const eqUser = eqBy((u: User) => u.id, eqNumber);   // equality is identity of the key

uniqueWith(eqUser)(users);                           // dedupes properly
elemWith(eqUser)(target)(users);                     // membership that actually matches
sortWith(ordBy((u: User) => u.age, ordNumber))(users); // sorts a copy, explicit comparator

eqStructural() compares by contents for plain data; eqStrict() keeps === where it is correct.

Use Validation when the caller needs every error, Result when it needs the first

Result short-circuits: flatMap and traverseArray stop at the first Err. That is correct when a step depends on its predecessor. It is wrong for independent checks — a form where the user fixes one field only to discover the next was also broken.

Validation is the applicative counterpart: combining failures concatenates their errors. It has no flatMap by design, because independence is what licenses accumulation (standard Rule 6.8).

import { sequenceStructValidation, isInvalid } from '@tsfpp/prelude';

const parsed = sequenceStructValidation({
  name:  parseName(raw),   // Validation<FieldIssue, string>
  email: parseEmail(raw),  // Validation<FieldIssue, Email>
});

if (isInvalid(parsed)) {
  return problemResponse(parsed.errors); // EVERY field issue, not just the first
}

Cross back into short-circuit domain code with validationToResult / resultToValidation.

Use isDefined for undefined filtering

When narrowing ReadonlyArray<T | undefined>, prefer isDefined over inline checks. It keeps call-sites concise and gives proper type narrowing in filter pipelines.

import { isDefined } from '@tsfpp/prelude';

const raw: ReadonlyArray<number | undefined> = [1, undefined, 2, undefined, 3];
const values = raw.filter(isDefined); // ReadonlyArray<number>

isDefined excludes only undefined. For null-or-undefined lifting, use fromNullable.

Reify thrown exceptions with tryCatch, not raw try/catch

tryCatch and tryCatchAsync exist for the adapter boundary — wrapping third-party APIs that throw. Inside your own code, return Result directly; don't throw and immediately catch.

// Yes
const parsed = tryCatch(
  () => JSON.parse(raw),
  (e) => `parse failed: ${String(e)}`,
);

// No — duplicates what tryCatch already does
let parsed: Result<unknown, string>;
try { parsed = ok(JSON.parse(raw)); }
catch (e) { parsed = err(String(e)); }

Prefer traverseArray to map-then-sequence

When mapping a fallible function over an array, traverseArray short-circuits on the first Err and returns Result<readonly B[], E> directly.

// Yes — short-circuits on first Err
const all = traverseArray(parseFoo)(rawItems);

// No — produces ReadonlyArray<Result<Foo, string>>, then needs manual sequencing
const all = rawItems.map(parseFoo);

Construct branded types only through their smart constructors

A branded type's invariants live in its constructor. Casting around the constructor (raw as NodeId) defeats the brand entirely.

// Yes — returns Option<NodeId> or Result<NodeId, E>
const id = mkNodeId(rawString);

// No — bypasses any validation the brand was meant to guarantee
const id = rawString as NodeId;

Use tap and tapErr for side effects, not by breaking the chain

Logging, metrics, and other observability effects belong in tap / tapErr. They preserve the value and keep the pipeline intact.

// Yes
const result = pipe(
  parseInput(raw),
  tap((v) => log.debug({ parsed: v })),
  flatMap(validate),
  tapErr((e) => log.warn({ error: e })),
);

// No — breaks composition for an effect
const parsed = parseInput(raw);
if (isOk(parsed)) log.debug({ parsed: parsed.value });
const validated = flatMap(validate)(parsed);
if (isErr(validated)) log.warn({ error: validated.error });

Depend on the Logger port, not a concrete logger

Application and domain layers should depend on the Logger interface and receive an implementation through dependency injection. Keep concrete logger libraries (for example pino or winston) in infrastructure adapters.

import { type Logger } from '@tsfpp/prelude';

type Deps = {
  readonly logger: Logger;
};

const runUseCase = (deps: Deps) => (userId: string): void => {
  deps.logger.info({ message: 'use-case started', userId });
};

Use LogEntry as the shared structured payload contract at call-sites. Include traceId for request-scoped work whenever available.

Use Result<Unit, E> instead of Result<void, E> for success-only results

When a fallible operation succeeds but produces no meaningful value — saving to a database, sending an event, writing a file — the success type should be Unit, not void. void is not a first-class value in TypeScript: you cannot store it, pass it, or serialise it. Unit is structurally undefined and works everywhere a value is expected.

import { ok, err, unit, type Result, type Unit } from '@tsfpp/prelude';

const saveSettings = (cfg: Config): Result<Unit, string> =>
  isValid(cfg) ? ok(unit) : err('invalid config');

Prefer ok(unit) over ok(undefined) — the named constant signals that the absence of a value is intentional, not a gap.

Pairing with pipe

The library ships its own pipe. No external dependency needed.

import { pipe, map, flatMap } from '@tsfpp/prelude';
// With pipe — reads top-to-bottom, follows the data flow
const result = pipe(
  parseNumber('40'),
  map((n) => n + 2),
  flatMap(toEven),
);

// Without pipe — reads inside-out
const result = flatMap(toEven)(map((n: number) => n + 2)(parseNumber('40')));

Convert string-keyed maps with toObject

Use toObject when an adapter boundary needs a plain object shape.

import { intoMap, toObject } from '@tsfpp/prelude';

const map = intoMap([
  ['service', 'billing'],
  ['region', 'eu-west-1'],
] as const);

const payload = toObject(map);
// { service: 'billing', region: 'eu-west-1' }

Further reading

  • RECIPES.md — patterns for building on top of the prelude

Scripts

pnpm run build
pnpm run typecheck
pnpm run lint
pnpm run test
pnpm run test:coverage

Release process

Releases are automated with Release Please.

  1. Use Conventional Commits in merged PRs.
  2. Release Please opens/updates a release PR.
  3. Merging the release PR publishes a GitHub release and updates CHANGELOG.md.

See .github/workflows/release-please.yml and release-please-config.json.

License

MIT