v2.6 — ~1.3 kB gzipped · zero deps · async included

Discriminated unions, without the boilerplate.

One schema gives you constructors, type guards, exhaustive matching, partial transforms, and collection ops. Built for the { type: 'x' } | { type: 'y' } world most TypeScript apps live in.

success / error you can match on

import { createUnion, match } from 'dismatch';

const Result = createUnion('status', {
  ok: (data: string) => ({ data }),
  error: (message: string) => ({ message }),
});

const greet = match(Result.ok('ready'))({
  ok: ({ data }) => `✓ ${data.toUpperCase()}`,
  error: ({ message }) => `✗ ${message}`,
});

Same logic, less ceremony

The variant name is the pattern.

Other matchers wrap every branch in { type: '...' } and ask you to opt into exhaustiveness. dismatch dispatches on the discriminator directly, and exhaustiveness is the default.

ts-pattern
import { match } from 'ts-pattern';

const label = match(state)
  .with({ type: 'idle' },    () => 'tap to load')
  .with({ type: 'loading' }, () => 'loading…')
  .with({ type: 'success' }, ({ data })   => `${data.length} users`)
  .with({ type: 'error' },   ({ reason }) => `failed: ${reason}`)
  .exhaustive();
dismatch
import { match } from 'dismatch';

const label = match(state)({
  idle:    () => 'tap to load',
  loading: () => 'loading…',
  success: ({ data })   => `${data.length} users`,
  error:   ({ reason }) => `failed: ${reason}`,
});

Why dismatch

One schema. The whole toolkit.

Constructors, type guards, exhaustive matching, partial transforms, collection ops — all generated from a single declaration. No boilerplate left to forget.

Compile-time exhaustiveness

Forget a branch and TypeScript stops you.

Matchers narrow the discriminator down to never. Miss a case and the compiler points at the missing key — no opt-in .exhaustive() call required.

matcher.ts
const label = match(state)({idle: () => 'tap to load',loading: () => '…',success: ({ data }) => `${data.length}`,error: ({ reason }) => `${reason}`,});
TS2322 Type '"loading"' is not assignable to type 'never'. Did you forget the "loading" branch?

Tiny

~1.3 kB gzipped.

  • zero runtime dependencies
  • sideEffects: false
  • per-function tree-shaking

RemoteData included

The five-state UI in one import.

idle · loading · refreshing · ok · failed — the shape every async screen wants.

import { RemoteData } from 'dismatch/remote-data';
import { match } from 'dismatch';

const view = match(state)({
  idle:       () => 'tap to load',
  loading:    () => 'loading…',
  refreshing: ({ data }) => `refreshing ${data.length}…`,
  ok:         ({ data }) => render(data),
  failed:     ({ error }) => error.message,
});

One schema, ten capabilities

createUnion is the whole API.

const Result = createUnion('status', {
  ok:    (data: string)    => ({ data }),
  error: (message: string) => ({ message }),
});

// All generated from one schema:
Result.ok('hi');             // constructor
Result.is('ok');             // type guard
Result.isKnown(payload);     // runtime check
Result.match({ … });         // exhaustive
Result.matchWithDefault({…});// partial
Result.map({ … });           // partial transform
Result.mapAll({ … });        // exhaustive transform
Result.groupBy(results);     // single-pass groups
Result.find(results, 'ok');  // first match, narrowed
Result.variants;             // ['ok','error']

Reusable matchers

Handlers-first. Matchers compose like values.

// Define once, reuse anywhere
const area = Shape.match({
  circle:    ({ radius })        => Math.PI * radius ** 2,
  rectangle: ({ width, height }) => width * height,
  triangle:  ({ base, height })  => 0.5 * base * height,
});

shapes.map(area);             // number[]
shapes.filter(Shape.is('circle'));

Type guards that narrow arrays

is(value, variant) — single, multi, sub-union.

import { is } from 'dismatch';

// Single variant
if (is(item, 'loading')) item; // narrowed → Loading

// Multi-variant — typed sub-union
const settled = results.filter((r) =>
  is(r, ['ok', 'error']),
); // (Ok | Error)[]

Collection ops you didn't know you needed

fold · groupBy · filterMap

import { fold, groupBy, filterMap } from 'dismatch';

const stats = fold(results, { ok: 0, err: 0 })({
  ok:    (acc) => ({ ...acc, ok:  acc.ok  + 1 }),
  error: (acc) => ({ ...acc, err: acc.err + 1 }),
});

const groups  = groupBy(results);        // { ok?: Ok[]; error?: Error[] }
const reasons = filterMap(results, {
  error: ({ message }) => message,       // ok variants skipped
});

No factory? No problem.

createPipeHandlers — handlers-first matchers for any typed union.

Already have your unions declared? Bind matchers to the type once, reuse the resulting functions everywhere — in .map, .filter, callbacks, or a functional pipe.

import { createPipeHandlers } from 'dismatch';

// Bind once, reuse anywhere — no generics at the call site
const shapeOps = createPipeHandlers<Shape>('type');

const area = shapeOps.match({
  circle:    ({ radius })        => Math.PI * radius ** 2,
  rectangle: ({ width, height }) => width * height,
});

shapes.map(area);                         // number[]
shapes.filter(shapeOps.is('circle'));     // Circle[]

No runtime wrappers · low exit cost

Plain { type, ... } objects in. Plain objects out.

Constructors return the same shape you would write by hand, so values serialize cleanly over the wire, debug without confusion, and feed switch or ts-pattern with no unwrapping. Removing dismatch later is a mechanical rewrite — see the exit path.

How it compares

ts-pattern matches any pattern. dismatch manages discriminated unions.

A scalpel for the one job most TypeScript apps do every day: { type: 'x' } | { type: 'y' }.

Capabilityplain TypeScriptswitch + tagged objectsts-patternpattern matcher@effect/matchinside the Effect ecosystemdismatchDU toolkit
Bundle weight0 — built in~7.7 kBecosystem-sized~3.2 kB
Zero dependencies
Exhaustive matchingvia never trick.exhaustive()default
Plain-object output (wire-serializable, no wrappers)N/A — matcher onlyclass instances
Source-level exit costn/alowhighlow — mechanical
Constructors auto-generatedvia Data.tagged
Typed array filtering with is()single + multi
Schema-aware runtime checkisKnown
Collection fold / count / partitionreduce by hand
Partial transforms (map / mapAll)

What it looks like

The shapes you reach for every week.

Same primitives, four common screens. Pick a tab — the same declaration drives the constructors, the matcher, and the type guards.

success or error — the everyday shape

const Result = createUnion('status', {
  ok:    (data: string)    => ({ data }),
  error: (message: string) => ({ message }),
});

const view = Result.match({
  ok:    ({ data })    => `got ${data}`,
  error: ({ message }) => `fail: ${message}`,
});

Live in your browser

Try the API without an install.

The playground bundles dismatch from a CDN, runs your TypeScript in the browser, and prints the output beside your code. Useful for spiking a union before you commit to it.

Open the playgroundPick a published version, hit Run
starter.tsdismatch@latest
import { createUnion, is, type InferUnion } from 'dismatch';

const Shape = createUnion('type', {
  circle: (radius: number) => ({ radius }),
  rectangle: (width: number, height: number) => ({ width, height }),
  triangle: (base: number, height: number) => ({ base, height }),
});

type Shape = InferUnion<typeof Shape>;

const area = Shape.match({
  circle:    ({ radius })        => Math.PI * radius ** 2,
  rectangle: ({ width, height }) => width * height,
  triangle:  ({ base, height })  => 0.5 * base * height,
});
$ run.ts✓ ready · output below

Two kilobytes from done

Install once. Stop hand-rolling tagged objects.

$
npm install dismatch

ts-pattern matches any pattern. dismatch manages discriminated unions. One schema for the { type: 'x' } | { type: 'y' } world that most TypeScript apps live in.