diff --git a/README.md b/README.md index 63dfa9b..5e86b41 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,14 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex). -## Synopsis - -```ts -import { match, P } from "tiny-pattern-ts"; - -const reply = (answer: "yes" | "no") => - match(answer) - .with(P.literal("yes"), (): "agreed" => "agreed") - .with(P.literal("no"), (): "declined" => "declined") - .exhaustive(); - -reply("yes"); // "agreed" -``` - ## Description -`tiny-pattern-ts` gives TypeScript the shape of F#-style pattern matching: -a value flows through a chain of patterns, the first one that matches runs its -handler, and the handler receives the value narrowed to that pattern's type. The -"patterns" are ordinary objects whose `matches` method is a TypeScript type -guard, so narrowing composes the way any other guard does. - -It is deliberately not a regex engine and not a macro. There is no transpiler -and no DSL to learn: `match(value)` returns a builder, `.with(pattern, handler)` -adds a case, and the chain ends in either `.exhaustive()` or `.otherwise(...)`. -The type-level contract is the feature — see -[development/library.md](./development/library.md) for the design decisions and -the known limitations. +`tiny-pattern-ts` brings F#-style pattern matching to TypeScript. Patterns are +ordinary objects whose `matches` method is a TypeScript type guard, so narrowing +composes the way any other guard does. It is deliberately not a regex engine and +not a macro: there is no transpiler and no DSL to learn, and the type-level +contract is the feature — see [development/library.md](./development/library.md) +for the design decisions and the known limitations. ## Requirements @@ -39,124 +19,6 @@ the known limitations. both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. - The package is **ESM-only** (no CommonJS shim). -## Examples - -### Literal matching and `exhaustive()` - -`.exhaustive()` returns the union of the handler return types and throws if no -case matched. Annotate handler returns when you want literal types rather than -`string`: - -```ts -type Answer = "yes" | "no"; - -const reply = (answer: Answer): "agreed" | "declined" => - match(answer) - .with(P.literal("yes"), (): "agreed" => "agreed") - .with(P.literal("no"), (): "declined" => "declined") - .exhaustive(); - -reply("yes"); // "agreed" -``` - -`exhaustive()` checks at runtime, not at compile time — TypeScript does not force -every union member to have a case (see -[development/library.md](./development/library.md#exhaustive-is-a-runtime-check)). -Use `.otherwise(...)` when a fallback is wanted: - -```ts -const label = (answer: Answer): string => - match(answer) - .with(P.literal("yes"), () => "agreed") - .otherwise(() => "not agreed"); -``` - -### Matching by `typeof` - -`P.type(name)` pairs an explicit type `T` with the runtime `typeof` name it -should test for: - -```ts -const describe = (value: unknown): string => - match(value) - .with(P.type("string"), (s) => `string of length ${s.length}`) - .with(P.type("number"), (n) => `number ${n.toFixed(2)}`) - .otherwise(() => "something else"); -``` - -The supported names are `string`, `number`, `boolean`, `bigint`, `symbol`, -`undefined`, `object`, and `function`. `"object"` matches non-null objects and -functions; `"undefined"` compares against `undefined` directly. - -### Structural matching and discriminated unions - -`P.shape(shape, refine?)` checks that every key in `shape` exists on the value. -A value that is itself a matcher is applied, otherwise it is compared with -strict equality. To narrow to a concrete type, pass a `refine` type guard: - -```ts -interface Circle { - readonly kind: "circle"; - readonly radius: number; -} - -interface Square { - readonly kind: "square"; - readonly side: number; -} - -type Shape = Circle | Square; - -const area = (shape: Shape): number => - match(shape) - .with( - P.shape({ kind: "circle" }, (v): v is Circle => "radius" in v), - (c) => Math.PI * c.radius ** 2, - ) - .with( - P.shape({ kind: "square" }, (v): v is Square => "side" in v), - (s) => s.side ** 2, - ) - .exhaustive(); -``` - -Without `refine`, `P.shape` returns a matcher for the shape's own type, not the -narrowed one. Nested matchers can be used in the shape object, for example -`P.shape({ name: P.type("string") })`. - -### Custom guards with `when` - -`P.when` takes a type guard and infers the narrowed type from it: - -```ts -const toNumber = (value: unknown): number => - match(value) - .with( - P.when((v): v is string => typeof v === "string"), - (s) => Number.parseInt(s, 10), - ) - .otherwise(() => 0); -``` - -### Widening with `any` - -`P.any(predicate)` takes a plain boolean predicate and a declared type `T`, -for cases where the predicate cannot be written as a type guard: - -```ts -const firstNumber = (items: readonly unknown[]): number | undefined => - match(items) - .with( - P.any( - (v) => - Array.isArray(v) && - v.every((item) => typeof item === "number"), - ), - (xs) => xs[0], - ) - .otherwise(() => undefined); -``` - ## API Yet to be implemented diff --git a/development/tooling.md b/development/tooling.md index d07e768..d1ab59f 100644 --- a/development/tooling.md +++ b/development/tooling.md @@ -105,8 +105,8 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json` #### Decision (2026-09) A type-aware rule that false-positives is silenced with a source-level -`oxlint-disable` directive (see `src/pattern.ts`, `src/match.ts`, -`src/index.test.ts`), not by turning the rule off in `.oxlintrc.json`. +`oxlint-disable` directive (see `src/primitive.ts`, +`src/primitive.test.ts`), not by turning the rule off in `.oxlintrc.json`. #### Why diff --git a/src/index.ts b/src/index.ts index 20f0ec7..096a964 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,6 @@ -export { match, P } from "./match.ts"; -export type { Matcher, Pattern } from "./pattern.ts"; +export { + getPrimitiveUnionMatcher, + getPrimitiveUnionMatcherPartial, + getPrimitiveUnionMatcherPartialW, + getPrimitiveUnionMatcherW, +} from "./primitive.ts"; diff --git a/src/match.ts b/src/match.ts deleted file mode 100644 index ae9672f..0000000 --- a/src/match.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { P, type Matcher, type Pattern } from "./pattern.ts"; - -type Cases = readonly (readonly [Matcher, (value: unknown) => R])[]; - -interface MatchBuilder { - with( - pattern: Matcher, - handler: (value: U) => V, - ): MatchBuilder; - exhaustive(): R; - otherwise(handler: (value: T) => R): R; -} - -const buildMatch = (value: T, cases: Cases): MatchBuilder => { - const apply = (): R | undefined => { - for (const [matcher, handler] of cases) { - if (matcher.matches(value)) { - return handler(value); - } - } - return undefined; - }; - - const builder = { - with( - pattern: Matcher, - handler: (value: U) => V, - ): MatchBuilder { - const nextCases: Cases = [ - ...cases, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - [pattern, handler as (value: unknown) => R | V], - ]; - return buildMatch(value, nextCases); - }, - exhaustive(): R { - const result = apply(); - if (result === undefined) { - throw new Error( - "tiny-pattern-ts: match.exhaustive() called with no matching case", - ); - } - return result; - }, - otherwise(handler: (value: T) => R): R { - for (const [matcher, run] of cases) { - if (matcher.matches(value)) { - return run(value); - } - } - return handler(value); - }, - }; - - return builder; -}; - -export const match = (value: T): MatchBuilder => - buildMatch(value, []); - -export type { Matcher, Pattern }; -export { P }; diff --git a/src/pattern.ts b/src/pattern.ts deleted file mode 100644 index e7b9e00..0000000 --- a/src/pattern.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Pattern matching primitives. Each constructor returns a lightweight - * matcher object whose `matches` method returns a type guard. - */ - -export interface Matcher { - readonly matches: (value: unknown) => value is T; -} - -const literalMatcher = < - const L extends string | number | boolean | null | undefined, ->( - value: L, -): Matcher => ({ - matches: (candidate): candidate is L => candidate === value, -}); -const typeMatcher = ( - type: - | "string" - | "number" - | "boolean" - | "bigint" - | "symbol" - | "undefined" - | "object" - | "function", - ): Matcher => { - const matches = (value: unknown): value is T => { - if (type === "undefined") { - return value === undefined; - } - if (type === "object") { - return ( - (typeof value === "object" && value !== null) || - typeof value === "function" - ); - } - return typeof value === type; - }; - return { matches }; - }, - whenMatcher = ( - predicate: (value: unknown) => value is T, - ): Matcher => ({ - matches: predicate, - }), - whenMatcherAny = ( - predicate: (value: unknown) => boolean, - ): Matcher => ({ - matches: (value: unknown): value is T => predicate(value), - }), - isNestedMatcher = (expected: unknown): expected is Matcher => - typeof expected === "object" && - expected !== null && - "matches" in expected, - // oxlint-disable-next-line typescript/no-unnecessary-type-parameters - keysMatch = (shape: S, candidate: object): boolean => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - for (const key of Object.keys(shape) as (keyof S)[]) { - if (!(key in candidate)) { - return false; - } - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const expected = shape[key], - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - actual = candidate[key as keyof object]; - if (isNestedMatcher(expected)) { - if (!expected.matches(actual)) { - return false; - } - } else if (actual !== expected) { - return false; - } - } - return true; - }, - structuralMatcher = ( - shape: S, - refine?: (value: S) => value is T, - ): Matcher => ({ - matches: (value: unknown): value is T => { - if (typeof value !== "object" || value === null) { - return false; - } - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const candidate = value as S; - if (!keysMatch(shape, candidate)) { - return false; - } - if (refine && !refine(candidate)) { - return false; - } - return true; - }, - }); - -export const P = { - literal: literalMatcher, - type: typeMatcher, - when: whenMatcher, - any: whenMatcherAny, - shape: structuralMatcher, -} as const; - -export type Pattern = Matcher;