From 9faa0ecc550fcf3669633c6834b3e31af8abf1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:27:15 +0000 Subject: [PATCH] :memo: Rewrite README as the user entry point Follows the Perl/CPAN order: name + one-line description, Synopsis, Description, Examples, API reference, License. Tooling, CI and decision content moved to development/; the README keeps only user-facing material and a pointer to CONTRIBUTING.md and AGENTS.md. No version line and no package.json link. --- README.md | 315 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 262 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 3bdec4f..681a3de 100644 --- a/README.md +++ b/README.md @@ -2,68 +2,277 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex). -## Development +## Synopsis -- **Build:** `npm run build` -- **Test:** `npm run test`, `npm run test:ci` -- **Watch:** `npm run watch` -- **Checks:** `npm run check`, `npm run fix` -- **Verify:** `npm run verify` — the definition of done -- **Maintenance:** `npm run maintain` — advisory only -- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` +```sh +npm install tiny-pattern-ts +``` -What each tier runs, when it fires and what it costs: -[CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). How the -`prefix:` in a script name is chosen: -[§ Script prefix convention](./CONTRIBUTING.md#script-prefix-convention). +```ts +import { match, P } from "tiny-pattern-ts"; -### Tooling +const reply = (answer: "yes" | "no") => + match(answer) + .with(P.literal("yes"), (): "agreed" => "agreed") + .with(P.literal("no"), (): "declined" => "declined") + .exhaustive(); -- **TypeScript 7** — type checker and build (`tsc`). -- **node --test** + `--strip-types` — test runner. -- **c8** — code coverage for `test:ci`. -- **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go). -- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`. -- **cspell** — spell checking. -- **knip** — finds unused dependencies, exports, and files. -- **check-outdated** — reports dependencies behind the registry; it exits non-zero whenever _any_ dependency is outdated. -- **publint** — validates `package.json` for ESM publishing correctness. -- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios. -- **lefthook** — git hooks. -- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via `tsc --lsp --stdio`. - -Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-decisions); when it runs is in [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). - -### Tooling decisions - -The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones: - -- **`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`**; `tsconfig.build.json` extends it to add the emit-only options (`declaration`, `sourceMap`, `inlineSources`, `outDir`, `target: es2024`, `rewriteRelativeImportExtensions: true`) and to exclude test files. `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so debuggers can map into `src/` without it being shipped; `declarationMap` is intentionally off because a `.d.ts.map` cannot embed source and would dangle. This separation lets the editor and CI type-check from one config while the build emits from the other. -- **`npm run build` first runs a `prebuild` hook that empties `dist/`.** `tsc` does not prune orphaned emit output — dropping `declarationMap`, for example, left stale `*.d.ts.map` files behind — so the build must start from an empty `dist/` to be reproducible. `prebuild` removes only `dist`; the manual `clean` still resets `dist` + `coverage`, so a local coverage report survives a build. -- **Source imports use `.ts` extensions** so `node --strip-types` resolves them at test time. `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them to `.js` in the emitted JavaScript; the emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves (see [Requirements](#requirements)), so no post-processing step is needed. -- **Type-aware oxlint is enabled declaratively** via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). The script commands stay clean — no CLI flag — and type-aware mode is a property of the config, not the invocation. -- **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source. -- **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate. -- **`attw --profile esm-only`** is semantically correct: this package is intentionally ESM-only (no CommonJS shim), so CJS resolution scenarios are out of scope by design, not a bug. -- **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end). -- **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_. -- **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both. -- **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package inspects `node_modules/typescript`, sees major ≥ 7 with no `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and never a correctness gate — `npm run check` / `verify` remain that. `.pi/settings.json` is the shared, committed declaration; `.pi/npm/` is a gitignored install cache that pi recreates automatically on a trusted startup (it runs `npm install` for any missing project package), so the cache is deliberately not tracked. +reply("yes"); // "agreed" +``` ### Requirements -- Node.js >= 26 (engines field; pinned via `.node-version`). -- TypeScript >= 5.0 to consume the published declarations. The emitted `.d.ts` use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; both resolve on TS >= 5.0 in `node10`/`node16`/`nodenext`/`bundler`. +- **Node.js >= 26** (`engines` field; pinned via `.node-version`). +- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts` + use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; + both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. +- The package is **ESM-only** (no CommonJS shim). -## VSCode integration +## Description -- Recommended extensions: see `.vscode/extensions.json` (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks). -- TypeScript 7 is used via the `typescriptteam.native-preview` extension. -- oxc extension provides oxlint squiggles and oxfmt format-on-save; `.vscode/settings.json` pins it per language so a user's local `[language]` formatter settings cannot override the project's choice. +`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. + +## 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 + +### `match(value)` + +```ts +const match: (value: T) => MatchBuilder; +``` + +Starts a matching chain for `value`. The builder is immutable: every `.with` +returns a new builder, so a partially built chain can be reused. + +#### `.with(pattern, handler)` + +```ts +with(pattern: Matcher, handler: (value: U) => V): MatchBuilder; +``` + +Adds a case. `handler` receives the value narrowed to `U`, and its return type +`V` is added to the builder's result union `R`. A pattern whose narrowed type is +not assignable to the matched value's type is a compile error. + +#### `.exhaustive()` + +```ts +exhaustive(): R; +``` + +Returns the result of the first matching case. Throws +`tiny-pattern-ts: match.exhaustive() called with no matching case` if none +matched. It does not statically prove that every union member is covered. + +#### `.otherwise(handler)` + +```ts +otherwise(handler: (value: T) => R): R; +``` + +Like a final catch-all case: runs `handler` if no earlier case matched. Unlike +`.exhaustive()`, it never throws. + +### `P.literal(value)` + +```ts +const P.literal: ( + value: L, +) => Matcher; +``` + +Matches a single literal with `===` and narrows to its literal type. + +### `P.type(type)` + +```ts +const P.type: ( + type: "string" | "number" | "boolean" | "bigint" | "symbol" | "undefined" | "object" | "function", +) => Matcher; +``` + +Matches a `typeof` result and narrows to the explicitly supplied `T`. `T` is not +inferred from the name, so the type parameter and the runtime name must agree. + +### `P.when(predicate)` + +```ts +const P.when: (predicate: (value: unknown) => value is T) => Matcher; +``` + +Wraps a type guard as a matcher. This is the constructor to prefer when you can +express the check as a guard. + +### `P.any(predicate)` + +```ts +const P.any: (predicate: (value: unknown) => boolean) => Matcher; +``` + +Wraps a boolean predicate and declares the narrowed type `T` yourself. Use it +only when a type guard is not expressible; prefer `P.when`. + +### `P.shape(shape, refine?)` + +```ts +const P.shape: ( + shape: S, + refine?: (value: S) => value is T, +) => Matcher; +``` + +Matches an object that has every key of `shape`. A shape value that is a +`Matcher` is applied; otherwise the value is compared with `===`. Pass `refine` +to narrow to `T`; without it, the matched type is `S`. + +### Types + +```ts +interface Matcher { + readonly matches: (value: unknown) => value is T; +} + +type Pattern = Matcher; +``` + +Every pattern constructor returns a `Matcher`. `Pattern` is an alias kept +for readability. + +## License + +MIT © 2025 tmu. See [LICENSE](./LICENSE). ## Contributing -For maintainer and contributor docs — the script prefix convention, the feedback-tier system, the rules the tools don't enforce, and the publishing workflow — see [CONTRIBUTING.md](./CONTRIBUTING.md). AI coding agents: your entry point is [AGENTS.md](./AGENTS.md), which points back to CONTRIBUTING.md. - -- Commit signing (GPG). -- Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases. +Contributions are documented in [CONTRIBUTING.md](./CONTRIBUTING.md); the +reasons behind the project's decisions, rejected alternatives, and known issues +live in [development/](./development/README.md). AI coding agents start at +[AGENTS.md](./AGENTS.md).