♻️ Tighten the development/ prose
Same decisions, rationale, rejected alternatives and known issues, said with less padding: ~5,530 -> ~4,730 words (-15%). Every fact from the first draft is kept; only the wording, duplicated lead-ins and restated context are cut.
This commit is contained in:
1 parent
7ea84b66fa
commit
74c39e1346
7 files changed
+341
-409
No files matched your search
+38
-48
@@ -1,116 +1,106 @@
|
||||
# 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.
|
||||
The type-level design of the public API and the limitations it carries. The
|
||||
user-facing reference is [README § API](../README.md#api).
|
||||
|
||||
## 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>`.
|
||||
`matches: (value: unknown) => value is T`; `Pattern<T>` is an alias.
|
||||
|
||||
#### 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.
|
||||
composes into a chain, so the library needs no DSL and no transpiler.
|
||||
- Because `matches` narrows, `match(value).with(pattern, handler)` types the
|
||||
handler with no cast and no runtime tag.
|
||||
- `Pattern<T>` is an alias, so either name is the same type.
|
||||
|
||||
## The builder is immutable
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
`match(value)` returns a builder whose `.with` returns a _new_ builder rather
|
||||
than mutating the current one.
|
||||
`.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.
|
||||
- `.with` widens the result type to `R | V`, which cannot be represented by a
|
||||
value that changes type in place.
|
||||
|
||||
## `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.
|
||||
`.exhaustive()` throws 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.
|
||||
- Proving coverage through a fluent chain would need a compiler plugin or a
|
||||
builder that tracks an uncovered-member set — out of proportion to this
|
||||
library's size.
|
||||
- The union of handler return types is still type-safe; only the chain's
|
||||
completeness is unchecked.
|
||||
|
||||
#### 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.
|
||||
- A missing case is a runtime `Error`, not a compile error. A caller wants
|
||||
either an `.otherwise(...)` (which never throws) or to prove coverage
|
||||
themselves. Tracking uncovered members is a possible future change.
|
||||
|
||||
## `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.
|
||||
`P.type<T>(name)` requires the caller to supply `T`; it is not inferred from the
|
||||
`typeof` string.
|
||||
|
||||
#### Why
|
||||
|
||||
- A runtime string cannot carry a TypeScript type. The caller pairs the two, and
|
||||
- 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.
|
||||
- `T` and the runtime `name` can disagree (`P.type<number>("string")` compiles),
|
||||
because nothing ties them together. Prefer `P.when` with a real type guard. A
|
||||
name-to-type conditional mapping is a possible future change.
|
||||
|
||||
## `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.
|
||||
`P.shape<S, T>(shape, refine?)` returns `Matcher<T>`, defaulting to `Matcher<S>`
|
||||
without a `refine`.
|
||||
|
||||
#### 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.
|
||||
- The shape's values may be matchers, so `S` describes the check, not the value;
|
||||
the `refine` guard is the explicit point where the value narrows to `T`.
|
||||
- Keys are checked with `in` rather than requiring an exact match, so a shape can
|
||||
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.
|
||||
- Without `refine` a discriminated-union case does not narrow, so `P.shape`
|
||||
alone is easy to misuse. 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`.
|
||||
`P.any<T>(predicate)` takes 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.
|
||||
- Some checks are cheap as a boolean but awkward as a type guard (for example an
|
||||
`every` over an array); `P.any` is the escape hatch.
|
||||
|
||||
#### Known issue
|
||||
|
||||
- Like `P.type`, the declared `T` is not proven by the predicate. Prefer
|
||||
- As with `P.type`, the declared `T` is not proven by the predicate. Prefer
|
||||
`P.when` whenever the check can be written as a guard.
|
||||
Reference in new issue
Block a user