📝 Add development/ docs for decisions and known issues

Category files under development/ replace the decision prose that was
scattered through README.md and CONTRIBUTING.md. Each decision is a block with
Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records
the public API contract and its limitations. AGENTS.md and backlog.tasks now
point at the new home.
This commit is contained in:
tmu committed 2026-09-15 13:26:59 +00:00
1 parent 97dfe9e7b4
commit dc7ce22fc5
9 files changed
+1049 -5

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
# Library design
The type-level design of the public API, and the limitations it carries. The
user-facing reference is [README § API](../README.md#api); this file records why
the API is shaped the way it is.
## A type guard is the single primitive
#### Decision (2026-09)
Every pattern constructor returns a `Matcher<T>` whose only member is
`matches: (value: unknown) => value is T`. `Pattern<T>` is an alias for
`Matcher<T>`.
#### Why
- A type guard is the one TypeScript construct that both narrows in an `if` and
composes into a chain, so the whole library can be built from it without a DSL
or transpiler.
- Because `matches` narrows, `match(value).with(pattern, handler)` can give the
handler the right type with no cast and no runtime type tag.
- Keeping `Pattern<T>` as an alias means a caller can name either; the type is
identical.
## The builder is immutable
#### Decision (2026-09)
`match(value)` returns a builder whose `.with` returns a _new_ builder rather
than mutating the current one.
#### Why
- A partially built chain can be stored and reused without one call site
affecting another.
- `.with` widens the result type `R` to `R | V`; a value that changes type in
place cannot be represented soundly. A new value per `.with` is what makes the
type-level accumulation work.
## `exhaustive()` is a runtime check
#### Decision (2026-09)
`.exhaustive()` throws at runtime when no case matched. It does not statically
prove that every member of the input union has a case.
#### Why
- TypeScript cannot require a fluent call chain to cover every union member
without a compiler plugin or a builder whose type tracks an uncovered-member
set. That machinery is out of proportion to the library's size.
- The union-of-return-types (`R`) is still type-safe; what is not guaranteed is
that the chain is complete.
#### Known issue
- A missing case is a runtime `Error`, not a compile error. A caller who wants
compile-time safety must either add a `.otherwise(...)` (which never throws) or
prove coverage themselves. Making the builder track uncovered members is a
possible future change; it is not in scope now.
## `P.type` takes the type as a parameter
#### Decision (2026-09)
`P.type<T>(name)` requires the caller to supply `T` explicitly; it is not
inferred from the `typeof` string.
#### Why
- A runtime string cannot carry a TypeScript type. The caller pairs the two, and
the compiler only checks that `T` is used consistently afterwards.
#### Known issue
- The type parameter and the runtime name can disagree (`P.type<number>("string")`
compiles), because nothing ties `T` to `name`. `P.when` with a real type guard
avoids the pairing entirely, so prefer it. A future revision could map the name
to a type with a conditional type; that is not in scope now.
## `P.shape` narrows only through `refine`
#### Decision (2026-09)
`P.shape<S, T>(shape, refine?)` returns `Matcher<T>`, defaulting to
`Matcher<S>` when no `refine` is given.
#### Why
- The shape object's values may be matchers, so the shape's literal type is not
the matched type: `S` describes the check, not the value. A `refine` type guard
is the explicit point where the value is narrowed to `T`.
- Checking keys with `in` (rather than requiring exact keys) lets a shape match a
wider object, which is how discriminated unions are handled.
#### Known issue
- Without `refine`, a discriminated-union case does not narrow, so `P.shape`
on its own is easy to misuse. The README shows the `refine` form; prefer
`P.when` when a guard is already available.
## `P.any` exists for non-guard predicates
#### Decision (2026-09)
`P.any<T>(predicate)` accepts a boolean predicate and a declared `T`.
#### Why
- Some checks are cheap to write as a boolean but awkward as a type guard (for
example an `every` over an array). `P.any` is the escape hatch for those.
#### Known issue
- Like `P.type`, the declared `T` is not proven by the predicate. Prefer
`P.when` whenever the check can be written as a guard.