Compare commits
19
Commits
ccbd0a297c
..
0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b58b06081 | ||
|
|
36e317bf17 | ||
|
|
0e56fff59e | ||
|
|
24cc007ab0 | ||
|
|
ebe58a9167 | ||
|
|
00ed6bee1e | ||
|
|
75807c4bd8 | ||
|
|
45df45df4b | ||
|
|
21b628f91d | ||
|
|
3c802ad7df | ||
|
|
5025fa3870 | ||
|
|
eb1acbd9d8 | ||
|
|
df09d3ae61 | ||
|
|
ac1fd60043 | ||
|
|
f5871c37c8 | ||
|
|
7a1eaa0d7f | ||
|
|
09011d838f | ||
|
|
8261759190 | ||
|
|
28d87b3387 |
No files matched your search
+32
-1
@@ -119,6 +119,35 @@ jobs:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
# Consumer typecheck against the minimum supported TypeScript (README
|
||||
# § Requirements), run over `dist/`'s emitted declarations and the whole
|
||||
# suite. Deliberately a separate job, not a step in `build`: the compiler is
|
||||
# a different major picked by `npx`, and it must never enter
|
||||
# `devDependencies`, the local `check`/`verify` tiers, or the lockfile. See
|
||||
# development/ci.md § TypeScript compatibility.
|
||||
compat:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Same baked image as `build` — without it this job re-downloads Node
|
||||
# per run (see docker/Dockerfile).
|
||||
container:
|
||||
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
# Reuse the exact `dist/` that `check`, `test:ci` and `publint` were
|
||||
# run against, so the compat gate judges the shipped artifact and
|
||||
# pays no rebuild.
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- run: npm run test:compat
|
||||
|
||||
# Advisory scans (dead code, dependency freshness). Non-blocking: surfaced in
|
||||
# the Actions tab for visibility, but must never gate a merge — so
|
||||
# continue-on-error and intentionally NOT in `publish`'s `needs`.
|
||||
@@ -142,7 +171,9 @@ jobs:
|
||||
|
||||
publish:
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
needs: build
|
||||
# `compat` gates the release: an artifact that is not consumable at the
|
||||
# claimed TypeScript floor must never ship.
|
||||
needs: [build, compat]
|
||||
runs-on: ubuntu-latest
|
||||
# Same baked image as `build` — setup-node still owns the registry-url
|
||||
# `.npmrc` rewrite here; only the Node download is skipped.
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"packages": ["npm:@spences10/pi-lsp@0.0.46"]
|
||||
"packages": ["npm:@spences10/pi-lsp@0.0.47"]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ first-action facts. Do not restate evolving prose here — it will drift.
|
||||
|
||||
## First action
|
||||
|
||||
- Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
|
||||
- Project: pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
|
||||
- **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed.
|
||||
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. **When you are looking for a symbol, reach for the LSP before `rg`/`grep`** — `lsp_references` / `lsp_find_symbol` / `lsp_definition` / `lsp_document_symbols` are semantic and cross-file, so they see shadowing, imports and overloads that a text search cannot; use `lsp_hover` to read inferred types on generic-heavy code. Use `rg` for what the LSP cannot see — doc prose, string literals, config, task lists, file discovery — and reconcile the two sets before editing (symbols from the LSP, strings and prose from `rg`). It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong.
|
||||
- **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
|
||||
|
||||
+25
-4
@@ -7,10 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- document the public API in the README: the four factories, when to use each,
|
||||
what the `W` (widening) suffix means, and examples that bind the handler-map
|
||||
function once
|
||||
## [0.9.0] - 2026-09-29
|
||||
|
||||
- add a CI `compat` job that type-checks the suite and a consumer fixture
|
||||
against the minimum supported TypeScript (5.9), consuming the built `dist/`
|
||||
and gating `publish`
|
||||
- correct the documented consumer floor to TypeScript >= 5.9 and drop the
|
||||
unsupported `node10` resolution claim
|
||||
|
||||
## [0.8.3] - 2026-09-28
|
||||
|
||||
- upgrade dependencies: oxfmt 0.71 (the only range widened), oxlint 1.86,
|
||||
oxlint-tsgolint 7.0.2003, cspell 10.3.5 and the rest within their existing
|
||||
ranges; no rule or format fallout
|
||||
- prune the completed items from the backlog; their rationale already lives in
|
||||
the README, `development/` and the released changelog entries
|
||||
|
||||
## [0.8.2] - 2026-09-25
|
||||
|
||||
- write the README's Synopsis and Examples sections
|
||||
- document the public API in the README
|
||||
- add TSDoc to the four public matcher factories
|
||||
- document alternatives
|
||||
|
||||
## [0.8.1] - 2026-09-23
|
||||
|
||||
@@ -106,7 +124,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- basic setup
|
||||
|
||||
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.1...main
|
||||
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.9.0...main
|
||||
[0.9.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.3...0.9.0
|
||||
[0.8.3]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.2...0.8.3
|
||||
[0.8.2]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.1...0.8.2
|
||||
[0.8.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.0...0.8.1
|
||||
[0.8.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.1...0.8.0
|
||||
[0.7.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.0...0.7.1
|
||||
|
||||
+8
-1
@@ -20,6 +20,10 @@ the rules so agents and humans don't diverge.
|
||||
|
||||
- **Build:** `npm run build`
|
||||
- **Test:** `npm run test`, `npm run test:ci`
|
||||
- **Compat (CI-only, manual locally):** `npm run test:compat` — type-check the
|
||||
suite + a consumer fixture against the minimum supported TypeScript; needs a
|
||||
built `dist/` and network access (`npx`). Never part of a hook or
|
||||
`check`/`verify`
|
||||
- **Watch:** `npm run watch` - re-runs tests on file save, humans only
|
||||
- **Checks:** `npm run check`, `npm run fix`
|
||||
- **Doc tests:** `npm run create:doc-tests` — compile the `ts`-tagged fences
|
||||
@@ -44,6 +48,7 @@ faster tiers catch less, slower tiers are more thorough":
|
||||
| `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s |
|
||||
| `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s |
|
||||
| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + coverage + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ |
|
||||
| CI compat (auto) | in CI only | `compat` job — `test:compat` over the built `dist/`; never a local tier (run by hand) — see [compat/](./compat) | ~15s |
|
||||
| CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s |
|
||||
| CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s |
|
||||
|
||||
@@ -142,7 +147,9 @@ CI step. Pick the prefix that matches the script's lifecycle:
|
||||
unit tests); `test:unit` skips the typecheck for fast local iteration;
|
||||
`test:coverage` runs c8 over the hand-written tests only; `test:doc` runs the
|
||||
generated doc examples without coverage; `test:ci` chains the two and fails
|
||||
below 100% coverage on `src/` (CI-only; `verify` stays coverage-free).
|
||||
below 100% coverage on `src/`; `test:compat` type-checks the suite + a
|
||||
consumer fixture against the minimum supported TypeScript via `npx` (both
|
||||
CI-only; `verify` stays coverage- and network-free).
|
||||
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
|
||||
`watch`.
|
||||
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
|
||||
|
||||
@@ -1,24 +1,163 @@
|
||||
# tiny-pattern-ts
|
||||
|
||||
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
|
||||
Exhaustive, type-safe pattern matching for TypeScript.
|
||||
|
||||
## Synopsis
|
||||
|
||||
```ts
|
||||
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
|
||||
|
||||
// 1. We have a union type
|
||||
type Contact =
|
||||
| { kind: "email"; address: string }
|
||||
| { kind: "phone"; number: string }
|
||||
| { kind: "messenger"; username: string };
|
||||
|
||||
// 2. Create a matcher providing the discriminant property
|
||||
const matchContact = getTaggedUnionMatcher<Contact>()("kind");
|
||||
|
||||
// 3. Define handlers for each branch of the union
|
||||
const formatContact = matchContact({
|
||||
email: (e) => `MAIL: ${e.address}`,
|
||||
phone: (p) => `PHONE: ${p.number}`,
|
||||
messenger: (m) => `MESSENGER: @${m.username}`,
|
||||
});
|
||||
|
||||
// 4. Call the matcher with a value
|
||||
const mailOutput = formatContact({ kind: "email", address: "ada@example.com" });
|
||||
assert.equal(mailOutput, "MAIL: ada@example.com");
|
||||
|
||||
const phoneOutput = formatContact({ kind: "phone", number: "+1 555 0100" });
|
||||
assert.equal(phoneOutput, "PHONE: +1 555 0100");
|
||||
```
|
||||
|
||||
## Description
|
||||
|
||||
`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 [Caveats](#caveats) for the limits.
|
||||
`tiny-pattern-ts` is a pattern-matching library for TypeScript.
|
||||
|
||||
The main goal of `tiny-pattern-ts` is to make pattern matching type-safe with a
|
||||
lean syntax. This is accomplished by being exhaustive and passing typed
|
||||
parameters per branch to the handlers — supported by an outstanding
|
||||
autocomplete and a tiny footprint. The matchers are data last and pipe-friendly:
|
||||
build the handler map once, then apply the resulting matcher to values
|
||||
(`match(value)`, or `pipe(value, match)`).
|
||||
|
||||
See [development/library.md](./development/library.md) for the design decisions
|
||||
and [Caveats](#caveats) for the limits.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm install tiny-pattern-ts
|
||||
```
|
||||
|
||||
## 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`.
|
||||
- **TypeScript >= 5.9** to consume the published declarations. The floor is set
|
||||
by the `type-fest` types the declarations use and is checked in CI against a
|
||||
consumer fixture; see [`compat/`](./compat) and
|
||||
[development/ci.md § TypeScript compatibility](./development/ci.md#typescript-compatibility).
|
||||
The emitted `.d.ts` keep their relative `.ts` specifiers, which resolve under
|
||||
`node16` / `nodenext` / `bundler`. The package exposes only an `exports` map
|
||||
(no `main` / top-level `types`), so the legacy `node10` resolver does not
|
||||
apply.
|
||||
- The package is **ESM-only** (no CommonJS shim).
|
||||
|
||||
## Examples
|
||||
|
||||
A few real-world recipes. Each binds the handler-map function once and reuses
|
||||
it, so the matcher is allocated a single time.
|
||||
|
||||
### Dispatch on a primitive union
|
||||
|
||||
A result code is itself a finite union, so `getPrimitiveUnionMatcher` keys a
|
||||
handler on each member:
|
||||
|
||||
```ts
|
||||
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
|
||||
|
||||
type ResultCode = "ok" | "created" | "no-content";
|
||||
|
||||
const toStatus = getPrimitiveUnionMatcher<ResultCode>()({
|
||||
ok: () => 200,
|
||||
created: () => 201,
|
||||
"no-content": () => 204,
|
||||
});
|
||||
|
||||
assert.equal(toStatus("ok"), 200);
|
||||
assert.equal(toStatus("created"), 201);
|
||||
assert.equal(toStatus("no-content"), 204);
|
||||
```
|
||||
|
||||
### Leave cases to a fallback
|
||||
|
||||
Pass a fallback as the second argument to handle only part of the universe; it
|
||||
receives the members the map leaves uncovered — here the parameter is
|
||||
`"deprecated" | "gateway-timeout"`:
|
||||
|
||||
```ts
|
||||
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
|
||||
|
||||
type Status = "active" | "beta" | "deprecated" | "gateway-timeout";
|
||||
|
||||
const rollout = getPrimitiveUnionMatcher<Status>()(
|
||||
{ active: () => "enabled", beta: () => "enabled" },
|
||||
(status) => `blocked (${status})`,
|
||||
);
|
||||
|
||||
assert.equal(rollout("active"), "enabled");
|
||||
assert.equal(rollout("deprecated"), "blocked (deprecated)");
|
||||
```
|
||||
|
||||
### Dispatch on a property union
|
||||
|
||||
The value does not have to be the union itself. When a single property carries a
|
||||
finite union, the tagged-union matcher keys on it and narrows the whole record to
|
||||
the selected value:
|
||||
|
||||
```ts
|
||||
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
|
||||
|
||||
interface Invoice {
|
||||
readonly currency: "eur" | "usd" | "jpy";
|
||||
readonly amount: number;
|
||||
}
|
||||
|
||||
const matchCurrency = getTaggedUnionMatcher<Invoice>()("currency");
|
||||
|
||||
const symbolOf = matchCurrency({
|
||||
eur: (i) => `€${i.amount.toFixed(2)}`,
|
||||
usd: (i) => `$${i.amount.toFixed(2)}`,
|
||||
jpy: (i) => `¥${i.amount.toFixed(0)}`,
|
||||
});
|
||||
|
||||
assert.equal(symbolOf({ currency: "usd", amount: 12.5 }), "$12.50");
|
||||
assert.equal(symbolOf({ currency: "jpy", amount: 900 }), "¥900");
|
||||
```
|
||||
|
||||
### Widen the return type
|
||||
|
||||
When the handlers return different types, reach for the widening `W` variant: the
|
||||
matcher's return is their union rather than one common type — here
|
||||
`string | string[] | undefined`:
|
||||
|
||||
```ts
|
||||
import { getPrimitiveUnionMatcherW } from "tiny-pattern-ts";
|
||||
|
||||
type Field = "name" | "tags" | "note";
|
||||
|
||||
const parse = getPrimitiveUnionMatcherW<Field>()({
|
||||
name: () => "Ada",
|
||||
tags: () => ["admin", "beta"],
|
||||
note: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(parse("name"), "Ada");
|
||||
assert.deepEqual(parse("tags"), ["admin", "beta"]);
|
||||
assert.equal(parse("note"), undefined);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
The package exports four factories. Two axes pick one:
|
||||
@@ -38,7 +177,7 @@ The package exports four factories. Two axes pick one:
|
||||
| `getTaggedUnionMatcherW<T>()` | the value is a discriminated object and handlers return different types | union of the handlers |
|
||||
|
||||
Bind the function that takes the handler map to a `match…` variable once and
|
||||
reuse it; the examples below do this, so the builder is allocated once.
|
||||
reuse it; the [Examples](#examples) do this, so the builder is allocated once.
|
||||
|
||||
### Primitive-union matchers
|
||||
|
||||
@@ -152,6 +291,43 @@ provable exhaustiveness — to automate what a `switch` and a default arm alread
|
||||
cover. The type-level cost of supporting open universes is recorded in
|
||||
[development/library.md](./development/library.md#supported-universes).
|
||||
|
||||
## Alternatives
|
||||
|
||||
### [`ts-pattern`](https://github.com/gvergnaud/ts-pattern)
|
||||
|
||||
- is the full structural matcher — nested and partial patterns, guards, unions,
|
||||
captures — exhausting at `.exhaustive()`
|
||||
- has a fluent `.with(…)` chain that is heavy on syntax; tiny-pattern-ts is one
|
||||
handler map
|
||||
- is about 2 kB minified and gzipped; tiny-pattern-ts is 0.2 kB
|
||||
- reach for it when you need a feature tiny-pattern-ts does not cover
|
||||
|
||||
### [Effect's `Match`](https://effect.website/docs/code-style/pattern-matching/)
|
||||
|
||||
- is the same piped matcher (`Match.type` / `Match.when` / `Match.exhaustive`),
|
||||
but only as part of the `effect` ecosystem
|
||||
- tiny-pattern-ts is standalone: no runtime dependency to buy into
|
||||
|
||||
### [`match-iz`](https://github.com/shuckster/match-iz)
|
||||
|
||||
- expresses patterns in the TC39 proposal's style, deciding each case at runtime
|
||||
- is written in JavaScript with hand-maintained declarations, so its types do
|
||||
not prove the cases exhaustive
|
||||
- tiny-pattern-ts does: exhaustiveness is a compile-time guarantee, not an
|
||||
`otherwise` fallback
|
||||
|
||||
### plain `switch` (baseline)
|
||||
|
||||
- is the zero-dependency baseline — pair it with
|
||||
[`eslint-plugin-strict-pattern-matching`](https://www.npmjs.com/package/eslint-plugin-strict-pattern-matching)
|
||||
for exhaustiveness
|
||||
- is a statement, not an expression, so it cannot produce a value directly
|
||||
- leaves the `never` guard to you; tiny-pattern-ts is an expression and does not
|
||||
need one
|
||||
|
||||
The [TC39 pattern-matching proposal](https://github.com/tc39/proposal-pattern-matching)
|
||||
is still stage 1, so userland libraries remain the only option today.
|
||||
|
||||
## License
|
||||
|
||||
MIT © 2026 tmu. See [LICENSE](./LICENSE).
|
||||
|
||||
+1
-25
@@ -7,32 +7,8 @@ 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 @low
|
||||
|
||||
v1.0:
|
||||
✔ API surface is stable and fully typed @done
|
||||
✔ Finalize public exports in `src/index.ts` @done
|
||||
✔ Document all exported types and functions @done
|
||||
✔ Add JSDoc for public APIs @done
|
||||
✔ Test coverage meets threshold @done
|
||||
✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
|
||||
✔ Achieve 100% branch coverage on `src/index.ts` @done
|
||||
|
||||
Matcher:
|
||||
✔ when using a union type as a property, the current behavior of tagged union matcher is @done
|
||||
to pass never to handler parameters
|
||||
→ new matcher function needed or can be fixed in tagged union matcher
|
||||
✔ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract<T, Record<K, V>>` still passes `never` to both the `x` and `undefined` handlers @done
|
||||
|
||||
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
|
||||
☐ Why do we do this? => exhaustiveness encoded type safe
|
||||
☐ Why this form? little syntax, data last, very small, autocomplete, strict typing in the handler; for more features use ts-pattern
|
||||
☐ Write migration guide for users coming from discriminated unions
|
||||
☐ Create backlog tasks for implementation
|
||||
✔ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API @done
|
||||
|
||||
Maintenance:
|
||||
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
|
||||
@@ -46,4 +22,4 @@ Maintenance:
|
||||
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
|
||||
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
|
||||
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
|
||||
☐ Add testing with TypeScript 5.0 baseline in CI
|
||||
✔ Add testing with the TypeScript floor in CI @done
|
||||
@@ -0,0 +1,89 @@
|
||||
// Consumer smoke test for the minimum supported TypeScript (see README
|
||||
// § Requirements). It imports the package by name, so it resolves through the
|
||||
// `exports` map to the emitted `dist/*.d.ts` — including the relative `.ts`
|
||||
// specifiers they keep — rather than to the source. Run by `npm run test:compat`
|
||||
// and the CI `compat` job only; never by `check` / `verify`.
|
||||
//
|
||||
// Why the `expectTypeOf` assertions: a compile that merely succeeds is a weak
|
||||
// oracle. An `any`-typed declaration would compile, but `expect-type`'s exact
|
||||
// equality rejects `any`, so the assertions prove the emitted types are real.
|
||||
// Every handler *parameter* is asserted too, not just the matcher: the handler's
|
||||
// member type comes from the `Member` inversion in the declarations, and a wrong
|
||||
// or `any` parameter would otherwise slip through, since the matcher's own
|
||||
// `(shape: T) => R` type is built from `T` and the handler returns. See
|
||||
// development/ci.md § TypeScript compatibility.
|
||||
|
||||
import { expectTypeOf } from "expect-type";
|
||||
import {
|
||||
getPrimitiveUnionMatcher,
|
||||
getPrimitiveUnionMatcherW,
|
||||
getTaggedUnionMatcher,
|
||||
getTaggedUnionMatcherW,
|
||||
} from "tiny-pattern-ts";
|
||||
|
||||
type ResultCode = "ok" | "created";
|
||||
|
||||
const toStatus = getPrimitiveUnionMatcher<ResultCode>()({
|
||||
ok: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"ok">();
|
||||
return "OK";
|
||||
},
|
||||
created: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"created">();
|
||||
return "CREATED";
|
||||
},
|
||||
});
|
||||
|
||||
expectTypeOf(toStatus).toEqualTypeOf<(shape: ResultCode) => string>();
|
||||
|
||||
// The widening twin keeps each handler's own return type in the union.
|
||||
const toStatusW = getPrimitiveUnionMatcherW<ResultCode>()(
|
||||
{
|
||||
ok: (s) => {
|
||||
expectTypeOf(s).toEqualTypeOf<"ok">();
|
||||
return "OK" as const;
|
||||
},
|
||||
},
|
||||
(rest) => {
|
||||
expectTypeOf(rest).toEqualTypeOf<"created">();
|
||||
return "CREATED" as const;
|
||||
},
|
||||
);
|
||||
|
||||
expectTypeOf(toStatusW).toEqualTypeOf<
|
||||
(shape: ResultCode) => "OK" | "CREATED"
|
||||
>();
|
||||
|
||||
type Contact =
|
||||
| { kind: "email"; address: string }
|
||||
| { kind: "phone"; number: string };
|
||||
|
||||
const format = getTaggedUnionMatcher<Contact>()("kind")({
|
||||
email: (e) => {
|
||||
expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>();
|
||||
return e.address;
|
||||
},
|
||||
phone: (p) => {
|
||||
expectTypeOf(p).toEqualTypeOf<{ kind: "phone"; number: string }>();
|
||||
return p.number;
|
||||
},
|
||||
});
|
||||
|
||||
expectTypeOf(format).toEqualTypeOf<(shape: Contact) => string>();
|
||||
|
||||
const formatW = getTaggedUnionMatcherW<Contact>()("kind")(
|
||||
{
|
||||
email: (e) => {
|
||||
expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>();
|
||||
return e.address;
|
||||
},
|
||||
},
|
||||
(rest) => {
|
||||
expectTypeOf(rest).toEqualTypeOf<{ kind: "phone"; number: string }>();
|
||||
return rest.number;
|
||||
},
|
||||
);
|
||||
|
||||
expectTypeOf(formatW).toEqualTypeOf<(shape: Contact) => string>();
|
||||
|
||||
export { format, formatW, toStatus, toStatusW };
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2024"],
|
||||
"module": "nodenext",
|
||||
"target": "es2024",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": false,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["../src", "../scripts", "*.ts"],
|
||||
"exclude": ["../src/doc-test"]
|
||||
}
|
||||
+2
-1
@@ -45,7 +45,8 @@
|
||||
"todotasks",
|
||||
"connor",
|
||||
"injective",
|
||||
"injectivity"
|
||||
"injectivity",
|
||||
"userland"
|
||||
],
|
||||
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
|
||||
}
|
||||
@@ -6,6 +6,12 @@ the job graph; this file records why it is shaped the way it is.
|
||||
## Pipeline
|
||||
|
||||
- **`build`** (push to `main` / tag) — build + correctness + packaging.
|
||||
- **`compat`** (CI only) — type-check the suite and a consumer fixture against
|
||||
the minimum supported TypeScript; consumes `build`'s `dist/` and gates
|
||||
`publish`. It has **no local tier**: it never runs in a hook or in
|
||||
`check` / `verify` / `test:ci`; run it by hand with
|
||||
`npm run build && npm run test:compat`. See
|
||||
[§ TypeScript compatibility](#typescript-compatibility).
|
||||
- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports,
|
||||
never fails the build.
|
||||
- **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`,
|
||||
@@ -130,6 +136,73 @@ the `build` job; `npm run verify` stays coverage-free.
|
||||
lists it under `--all`. It carries a file-level `/* c8 ignore start */` with
|
||||
the reason. Adding runtime code there means removing that directive.
|
||||
|
||||
## TypeScript compatibility
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
A dedicated `compat` job runs `npm run test:compat` — the `npx`-pinned
|
||||
TypeScript 5.9 compiler (`typescript@5.9.2`) over `compat/tsconfig.json` —
|
||||
against the `dist/` artifact `build` produced, and `publish` requires it. The
|
||||
floor is TypeScript 5.9, pinned in the `test:compat` script itself (the single
|
||||
source of truth) and documented in [README § Requirements](../README.md#requirements).
|
||||
|
||||
#### Why
|
||||
|
||||
- The compiler is a **different major** from the repo's TypeScript 7, so it is
|
||||
resolved by `npx` at run time. It must never appear in `devDependencies`: that
|
||||
would install it for every local `npm ci` and drift the lockfile, which would
|
||||
put a second compiler in the local `check` / `verify` loop and every editor.
|
||||
- **CI-only is the point.** `npx` fetches over the network — like `maintain`'s
|
||||
scans, a network-bound check is never a local feedback tier (see
|
||||
[workflow.md § Feedback tiers](./workflow.md#feedback-tiers)). It is not in
|
||||
`check`, `verify`, `test:ci` or any hook; locally it is run **by hand** with
|
||||
`npm run build && npm run test:compat`.
|
||||
- **`compat` consumes `build`'s artifact** rather than rebuilding, so it judges
|
||||
the exact bytes `check`, `test:ci` and `publint` saw.
|
||||
- **`compat` gates `publish`** because the types are the feature: an artifact
|
||||
that is not consumable at the advertised floor must not ship.
|
||||
- **The fixture is a consumer, not a unit test.** `compat/fixture.ts` imports
|
||||
the package by name (`tiny-pattern-ts`), so it resolves through the `exports`
|
||||
map to `dist/index.d.ts` and exercises the emitted declarations' relative
|
||||
`.ts` specifiers — not the source. `expectTypeOf`'s exact-equality assertions
|
||||
are load-bearing: a bare compile would also pass if a declaration collapsed to
|
||||
`any`; they reject that.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- **A `devDependencies` alias** (`npm:typescript@5.9`): installs the legacy
|
||||
compiler locally, defeating "CI only".
|
||||
- **A second lockfile / sub-project** (`compat/` with its own `npm ci`): a
|
||||
pinned, reproducible matrix, but a whole extra lockfile to maintain for one
|
||||
compiler. `npx -p` is enough.
|
||||
- **A `paths` / `moduleSuffixes` redirect** to typecheck the _existing_ suite
|
||||
against `dist/` without touching it: `paths` cannot remap the relative
|
||||
`./index.ts` imports the tests use; `moduleSuffixes` only lets _missing_
|
||||
source resolve to suffixed copies, so it would need generated `.compat.ts`
|
||||
declarations staged into `src/` (plus excludes). Both spend more than the
|
||||
fixture buys. See the [handover](../backlog.tasks) discussion.
|
||||
- **Writing the fixture against source** (relative import): it would prove the
|
||||
source compiles under 5.9, not that the _published_ declarations do, which is
|
||||
the promise consumers rely on.
|
||||
- **Replacing `attw`**: `attw` owns the full resolution matrix
|
||||
(`node10`/`node16`/`nodenext`/`bundler`); `compat` answers only "does the
|
||||
documented floor compile the artifact".
|
||||
|
||||
#### Known issue
|
||||
|
||||
- `@tsconfig/node26` cannot be extended: its `lib: ["es2025", ...]` and
|
||||
`target: es2025` are rejected by 5.9 (`TS6046`). `compat/tsconfig.json`
|
||||
extends only `@tsconfig/strictest` and sets `lib` / `target: es2024`, the
|
||||
ceiling 5.9 accepts.
|
||||
- `skipLibCheck: false` is deliberate — it is what makes the floor honest
|
||||
(`type-fest` pins it at 5.9), rather than hiding a broken dependency d.ts
|
||||
behind `true`.
|
||||
- The version appears in both the `test:compat` script and the README; a floor
|
||||
bump is a two-file change. The script is authoritative.
|
||||
- `src/doc-test` is excluded from `compat/tsconfig.json`. The generated examples
|
||||
are checked against the source by their own project; `test:compat` covers the
|
||||
suite plus the fixture.
|
||||
|
||||
## Coverage serving
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
@@ -107,8 +107,8 @@ Each factory is two overloads whose order is load-bearing:
|
||||
- **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands /
|
||||
defaulted type-param guards.** None change inference or evaluation order;
|
||||
`in`/`out` on the handler map broke contextual typing outright. `NoInfer`
|
||||
specifically leaks into the emitted `.d.ts`, raising the consumer floor to
|
||||
TypeScript 5.4 (README promises `>= 5.0`).
|
||||
specifically leaks into the emitted `.d.ts`, which would raise the consumer
|
||||
floor above the documented one (see [README § Requirements](../README.md#requirements)).
|
||||
- **Union merge**, **overload merge with only the exhaustive arm last**,
|
||||
**inferred universe**, **conditional `RequireKeys`**, **cases-first curried** —
|
||||
decided against while the API was single-object; their reasons (reported
|
||||
|
||||
+13
-12
@@ -196,27 +196,28 @@ joining the oxfmt-superseded rules already off.
|
||||
are part of the public API.
|
||||
- The narrower scope keeps the signal high without config-file boilerplate.
|
||||
|
||||
### `knip` lists `src/index.ts` as an entry
|
||||
### `knip` does not list the library entry
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
`knip.json` declares `"entry": ["src/index.ts", "scripts/*.ts"]`.
|
||||
`knip.json` declares `"entry": ["scripts/*.ts"]`; the library entry
|
||||
`src/index.ts` is not listed.
|
||||
|
||||
#### Why
|
||||
|
||||
- Supplying `entry` **replaces** knip's default entry detection, which otherwise
|
||||
derives the public entry from `package.json` `exports`. Adding `scripts/*.ts`
|
||||
there therefore dropped the library entry, so knip resolved the package through
|
||||
its `dist/index.js` output and reported the unreferenced source entry file
|
||||
`src/index.ts` as an unused file.
|
||||
- Naming the source entry restores the link between the public API and the
|
||||
source graph without pointing knip at build output.
|
||||
- knip derives the public entry from `package.json` `exports` and resolves it to
|
||||
`src/index.ts` itself, so naming the file is a redundant pattern and
|
||||
`maintain:knip` reports it as a configuration hint.
|
||||
- Listing `scripts/*.ts` does not switch that derivation off: the scope stays
|
||||
clean with or without `dist/`, and with the barrel's own test removed.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- `"src/index.ts"` in `entry`: it silenced an `unused files` report for the
|
||||
barrel, which the derivation above no longer produces; keeping it only adds a
|
||||
hint.
|
||||
- `paths` mapping `dist/index.*` back to `src/index.ts`: more config to model a
|
||||
relation the explicit entry states directly, and it would break whenever the
|
||||
build layout changes.
|
||||
relation knip already resolves.
|
||||
|
||||
### `maintain:outdated` ignores `@types/node`
|
||||
|
||||
@@ -327,7 +328,7 @@ of a hand-rolled JSON-RPC client.
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
`@spences10/pi-lsp` is pinned to `0.0.46` and used read-only.
|
||||
`@spences10/pi-lsp` is pinned to `0.0.47` and used read-only.
|
||||
|
||||
#### Why
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"entry": ["src/index.ts", "scripts/*.ts"],
|
||||
"entry": ["scripts/*.ts", "compat/*.ts"],
|
||||
"ignoreDependencies": ["@runwisp/pubv"]
|
||||
}
|
||||
Generated
+376
-354
File diff suppressed because it is too large.
Load diff
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tiny-pattern-ts",
|
||||
"version": "0.8.1",
|
||||
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
|
||||
"version": "0.9.0",
|
||||
"description": "Exhaustive, type-safe pattern matching for TypeScript",
|
||||
"keywords": [
|
||||
"adt",
|
||||
"algebraic-data-types",
|
||||
@@ -61,6 +61,7 @@
|
||||
"maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node",
|
||||
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
|
||||
"test:ci": "npm run test:coverage && npm run test:doc",
|
||||
"test:compat": "npx --yes --package typescript@5.9.2 tsc --project compat/tsconfig.json",
|
||||
"test:coverage": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types $(git ls-files 'src/*.test.ts')",
|
||||
"test:doc": "node --test --strip-types \"src/doc-test/__generated__/*.test.ts\"",
|
||||
"test:unit": "node --test --strip-types \"src/**/*.test.ts\"",
|
||||
@@ -88,7 +89,7 @@
|
||||
"knip": "^6.34.0",
|
||||
"lefthook": "^2.1.12",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"oxfmt": "^0.70.0",
|
||||
"oxfmt": "^0.71.0",
|
||||
"oxlint": "^1.83.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"publint": "^0.3.24",
|
||||
|
||||
Reference in new issue
Block a user