From ce3d6187577b7539e7f1f95e1c6439b88a8f9e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 21:57:42 +0000 Subject: [PATCH 1/4] :fire: Remove the example index spec The spec only exercised the template's match/P example API. Removing it first lets the modules and exports it imports go next without leaving a dangling reference. --- development/testing.md | 6 ++-- src/index.test.ts | 71 ------------------------------------------ 2 files changed, 3 insertions(+), 74 deletions(-) delete mode 100644 src/index.test.ts diff --git a/development/testing.md b/development/testing.md index 06a1986..5c674d5 100644 --- a/development/testing.md +++ b/development/testing.md @@ -27,8 +27,8 @@ before the implementation. - Runtime-first (classic red/green): it verifies the value, not the contract, and the contract is the product. -- Testing the type only: it would not catch handler wiring, `exhaustive()` - throwing, or the `otherwise` fallback (see `src/index.test.ts`). +- Testing the type only: it would not catch handler dispatch or the `_` + fallback (see `src/primitive.test.ts`). The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). @@ -66,7 +66,7 @@ separated by a blank line; an empty block drops its label. ## Known issues - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - test files that use it (`src/index.test.ts`, `src/primitive.test.ts`) carry a + test files that use it (`src/primitive.test.ts`) carry a file-level `oxlint-disable typescript/no-floating-promises` with an explanatory comment. It is a known false positive, not a rule worth disabling project-wide (see diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index ef6a58e..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ -import { strict as assert } from "node:assert"; -import { test } from "node:test"; - -import { expectTypeOf } from "expect-type"; - -import { type Matcher, P, match } from "./index.ts"; - -test("match returns a builder", () => { - // Act - const builder = match("x"); - - // Assert - expectTypeOf(builder).toHaveProperty("with"); - expectTypeOf(builder).toHaveProperty("exhaustive"); - expectTypeOf(builder).toHaveProperty("otherwise"); -}); - -test("P.literal narrows to its literal type", () => { - // Act - const matcher = P.literal("yes"); - - // Assert - expectTypeOf(matcher).toExtend>(); - assert.equal(matcher.matches("yes"), true); - assert.equal(matcher.matches("no"), false); -}); - -test("P.type narrows to the typeof target", () => { - // Act - const matcher = P.type("string"); - - // Assert - expectTypeOf(matcher).toExtend>(); - assert.equal(matcher.matches("hi"), true); - assert.equal(matcher.matches(42), false); -}); - -test("exhaustive() returns the union of handler return types", () => { - // Act - const result = match<"a" | "b">("a") - .with(P.literal("a"), () => 1 as const) - .with(P.literal("b"), () => "two" as const) - .exhaustive(); - - // Assert - expectTypeOf(result).toEqualTypeOf<1 | "two">(); - assert.equal(result, 1); -}); - -test("otherwise() falls back when no case matches", () => { - // Act - const result = match<"x" | "y" | "z">("z") - .with(P.literal("x"), (v): string => `got ${v}`) - .otherwise((v): string => `fallback ${v}`); - - // Assert - assert.equal(result, "fallback z"); -}); - -test("exhaustive throws when no case matches", () => { - // Assert - assert.throws( - () => - match<"a" | "b" | "c">("c") - .with(P.literal("a"), () => "A") - .with(P.literal("b"), () => "B") - .exhaustive(), - /no matching case/, - ); -}); From a3eb6183af3e34422f8bff6ed1d4010b98529d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 21:57:55 +0000 Subject: [PATCH 2/4] :fire: Remove the match and pattern example modules They were the template's demo API, not the library's real surface. Drop them, their README walkthrough, and the tooling note that cited their disable directives, and point index.ts at the primitive matchers that remain. --- README.md | 150 ++--------------------------------------- development/tooling.md | 4 +- src/index.ts | 8 ++- src/match.ts | 62 ----------------- src/pattern.ts | 105 ----------------------------- 5 files changed, 14 insertions(+), 315 deletions(-) delete mode 100644 src/match.ts delete mode 100644 src/pattern.ts 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; From 16092350b85fcaa912fa18848b6c5baf25b16e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 21:58:14 +0000 Subject: [PATCH 3/4] :memo: Check off example-code removal in backlog Note the finished work under [Unreleased] and mark the task and its subtasks done; retarget the v1.0 coverage tasks at the surviving primitive module. --- CHANGELOG.md | 1 + backlog.tasks | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64385dd..2703596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - allow ternaries and lowercase comments in oxlint - switch `src/primitive.ts` prose from block comments to line comments +- remove the template's `match`/`P` example modules and their documentation, and point `src/index.ts` at the primitive matchers ## [0.1.8] - 2026-09-16 diff --git a/backlog.tasks b/backlog.tasks index f32e774..69249df 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -5,20 +5,26 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format. --- Setup: -☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high +☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low ✔ straighten oxc rules @done ✔ oxc forbids ternary => remove oxc rule @done ✔ oxc wants comments to start comments with a capital letter => remove oxc rule @done ✔ there already exists a /* */ block comment in primitive.ts, => change to multi-line comment @done +✔ Remove example code files and its documentation and its exports from index.ts @high @done + ✔ remove src/match.ts and its documentation @high @done + ✔ remove src/pattern.ts and its documentation @high @done + ✔ remove src/index.test.ts and its documentation @high @done + ✔ exports from `src/index.ts` should only be the public API surface @done + + v1.0: ☐ API surface is stable and fully typed ☐ Finalize public exports in `src/index.ts` ☐ Document all exported types and functions ☐ Add JSDoc for public APIs ☐ Test coverage meets threshold - ☐ Achieve 100% branch coverage on `src/pattern.ts` - ☐ Achieve 100% branch coverage on `src/match.ts` + ☐ Achieve 100% branch coverage on `src/primitive.ts` ☐ Achieve 100% branch coverage on `src/index.ts` Bugs: From 65e885989b2ae56a11da727eb0232be43f32d9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 16 Sep 2026 22:03:28 +0000 Subject: [PATCH 4/4] :memo: Backlog the README walkthrough restoration The example-code removal stripped the synopsis and examples with the template modules; track bringing them back around the real API, with the previous section order recorded so its placement is not re-guessed. --- backlog.tasks | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backlog.tasks b/backlog.tasks index 69249df..448a052 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -34,6 +34,9 @@ Enhancements: ☐ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium Documentation: +☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place + → previous section order: title, tagline, Synopsis, Description, Requirements, Examples, API, License, Contributing + → previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any ☐ Create `examples/` directory with runnable snippets ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Write migration guide for users coming from discriminated unions