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/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/backlog.tasks b/backlog.tasks index f32e774..448a052 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: @@ -28,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 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/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.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/, - ); -}); 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;