Amir Gorji
← Back to home

TypeScript's filter lies to you.

TypeScript's built-in array methods — filter, map, reduce — don't know about discriminated unions. They treat a Shape[] as just an array. When you mix union-aware logic with these methods, you get silent type widening, missing compile-time safety, and a lot of boilerplate. Here's where it breaks down, and how dismatch fixes it.

Problem 1: filter doesn't narrow

Say you have a union of shapes:

typescript
type Circle    = { type: "circle";    radius: number };
type Rectangle = { type: "rectangle"; width: number; height: number };
type Triangle  = { type: "triangle";  base: number;  height: number };
 
type Shape = Circle | Rectangle | Triangle;

You want only the circles. The obvious approach:

typescript
const circles = shapes.filter(s => s.type === "circle");
//    ^? Shape[]

TypeScript infers Shape[] — not Circle[]. The predicate is a plain boolean function, and TypeScript has no way to know that true means "this is specifically a Circle." You end up casting:

typescript
const circles = shapes.filter(s => s.type === "circle") as Circle[];

That cast is a lie you have to maintain. If Circle changes, the cast won't warn you.

The fix is a type predicate — a function typed as (s: Shape) => s is Circle. Writing one by hand for every variant in every union gets old fast. dismatch generates them:

typescript
import { createUnion, type InferUnion } from "dismatch";
 
const Shape = createUnion({
  circle:    (radius: number) => ({ radius }),
  rectangle: (width: number, height: number) => ({ width, height }),
  triangle:  (base: number, height: number)  => ({ base, height }),
});
 
type Shape = InferUnion<typeof Shape>;
 
// Narrowed — no cast needed
const circles = shapes.filter(Shape.is("circle"));
//    ^? Circle[]
 
// Multi-variant narrowing — also works
const flat = shapes.filter(Shape.is(["rectangle", "triangle"]));
//    ^? (Rectangle | Triangle)[]

Shape.is("circle") is a curried type predicate. TypeScript sees it as (s: Shape) => s is Circle, and narrows the result correctly.

If you're working with a union you didn't define with createUnion, the standalone is function works the same way:

typescript
import { is } from "dismatch";
 
// Inline type guard in an if block
if (is(shape, "circle")) {
  shape.radius; // narrowed
}

Problem 2: Exhaustive map over a union array

Mapping a union array to a single output type is a common operation. The vanilla approach:

typescript
function getArea(shape: Shape): number {
  if (shape.type === "circle")    return Math.PI * shape.radius ** 2;
  if (shape.type === "rectangle") return shape.width * shape.height;
  // forgot triangle — TypeScript is fine with this if the function returns number | undefined
}
 
const areas = shapes.map(getArea);

If getArea doesn't have an explicit return type, TypeScript happily infers number | undefined. If it does have a return type, you get a compile error — but only on the function itself, not at the callsite. And if you later add a Triangle variant to Shape, nothing tells you to update getArea.

With dismatch, the match object is the handler. Exhaustiveness is encoded in its type:

typescript
// Define once — this is a reusable typed function, not a one-shot match
const getArea = Shape.match({
  circle:    ({ radius })        => Math.PI * radius ** 2,
  rectangle: ({ width, height }) => width * height,
  triangle:  ({ base, height })  => 0.5 * base * height,
});
 
const areas = shapes.map(getArea);
//    ^? number[]

If you add a hexagon variant to Shape and forget to add it to Shape.match({...}), TypeScript errors at the Shape.match call — not silently at runtime. Every callsite that uses getArea is automatically protected.

Shape.match returns a reusable function — define it once at module scope and pass it directly to .map(). No wrapper lambdas.

If you want a fallback for variants you don't care about, matchWithDefault handles that:

typescript
const label = Shape.matchWithDefault({
  circle: ({ radius }) => `circle r=${radius}`,
  Default: () => "other",
});
 
shapes.map(label); // string[]

Problem 3: Partitioning by variant

Splitting an array into typed sub-arrays by variant is surprisingly common — and surprisingly painful without the right tool.

The manual approach with reduce:

typescript
const { circles, others } = shapes.reduce<{
  circles: Circle[];
  others: (Rectangle | Triangle)[];
}>(
  (acc, shape) => {
    if (shape.type === "circle") {
      acc.circles.push(shape);     // still Shape, not Circle
    } else {
      acc.others.push(shape as Rectangle | Triangle); // cast required
    }
    return acc;
  },
  { circles: [], others: [] },
);

You need the cast because push(shape) inside an if block doesn't narrow the array type. The accumulator type has to be written out explicitly. And if you want to split into three buckets, the reduce grows proportionally.

dismatch has partition — a single function that splits in one pass with narrowed types on both sides:

typescript
import { partition } from "dismatch";
 
const [circles, rest] = partition(shapes, "circle");
//      ^? Circle[]   ^? (Rectangle | Triangle)[]

Both sides are fully typed. No casts, no reduce, no explicit accumulator type.

For multi-variant partitions:

typescript
const [flat, curved] = partition(shapes, ["rectangle", "triangle"]);
//      ^? (Rectangle | Triangle)[]   ^? Circle[]

Aggregating across variants: fold and count

For single-pass aggregation across all variants, fold is the right tool. Compare to a reduce + switch:

typescript
// Vanilla TypeScript
const stats = shapes.reduce(
  (acc, shape) => {
    switch (shape.type) {
      case "circle":    return { ...acc, totalArea: acc.totalArea + Math.PI * shape.radius ** 2 };
      case "rectangle": return { ...acc, totalArea: acc.totalArea + shape.width * shape.height };
      case "triangle":  return { ...acc, totalArea: acc.totalArea + 0.5 * shape.base * shape.height };
    }
  },
  { totalArea: 0 },
);
// Not exhaustive — adding a new variant won't cause a compile error here
typescript
// dismatch
import { fold } from "dismatch";
 
const stats = fold(shapes, { circles: 0, totalArea: 0 })({
  circle:    (acc, { radius })        => ({ circles: acc.circles + 1, totalArea: acc.totalArea + Math.PI * radius ** 2 }),
  rectangle: (acc, { width, height }) => ({ ...acc,                   totalArea: acc.totalArea + width * height }),
  triangle:  (acc, { base, height })  => ({ ...acc,                   totalArea: acc.totalArea + 0.5 * base * height }),
});

fold is exhaustive — missing a variant is a type error. Single pass, no switch, no separate accumulator type annotation required.

When you only need a count, count is even simpler:

typescript
import { count } from "dismatch";
 
count(shapes, "circle");                     // number of circles
count(shapes, ["rectangle", "triangle"]);   // combined count

TypeScript has everything it needs to make array operations on discriminated unions fully type-safe. It just doesn't wire them together into something usable. filter can't narrow, map can't enforce exhaustiveness, and reduce can't partition without casts. dismatch does all of it — with no runtime overhead and no extra type annotations.

Full docs at dismatch.dev.