16 Commits
Author SHA1 Message Date
tmu 73e5bc0093 🔀 Merge chore/straighten-oxc-rules into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 23s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-16 21:50:14 +00:00
tmu 08513b36a9 📝 Check off straighten-oxc-rules in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done.
2026-09-16 21:46:04 +00:00
tmu 068d6b4998 ♻️ Use line comments in primitive matchers
Replace the three `/* */` prose blocks with `//` line comments, matching
the rest of the codebase and the comment style the oxlint config now
allows.
2026-09-16 21:46:00 +00:00
tmu fa0b7f2e84 🔧 Allow ternaries and lowercase comments
Turn off `eslint/no-ternary` and `eslint/capitalized-comments` in the
project config: both encode a style the project rejects, so they belong in
the config's rule map rather than a per-site disable.

Scope the adjacent `oxlint-disable` decision to false positives and
record why a rejected stylistic rule is turned off project-wide.
2026-09-16 21:45:54 +00:00
tmu 71ecd508b7 🔀 Merge feature/primitve-pattern-matching into main 2026-09-16 21:40:57 +00:00
tmu c13bc2f408 📝 Document why any is retained in primitive matchers
Rewriting without any was evaluated; the alternatives were more
complex than the current solution. Record the rationale next to the
two oxlint-disable sites so the suppression is not re-litigated.
2026-09-16 21:39:46 +00:00
tmu 8e2691e6aa 📝 Will add backlog items found while working 2026-09-16 20:43:01 +00:00
tmu 16650b07e3 📝 Backlog primitive literal coverage questions 2026-09-16 20:01:20 +00:00
tmu a0f0894339 ✅ Spec mixed string and numeric union keys 2026-09-16 20:01:12 +00:00
tmu ed71929365 ✅ Check matcher handler params
The primitive union matchers invoke every handler with the matched
literal, but the specs wrote their handlers with zero parameters, so
the passed value and its inferred key-literal type went untested.

Declare the parameter on each such handler and pin it with
expectTypeOf, matching the style the two already-correct handlers used.
The `_` fallbacks assert the whole union rather than a single literal.
Return-type expectations are unchanged, so the W / non-W widening
distinctions are still covered.
2026-09-16 19:29:04 +00:00
tmu cce030b8e5 🐛 Type partial W matcher without an any cast
getPrimitiveUnionMatcherPartialW was declared with the intended
P-inferring signature but assigned via `as any`, hiding that the
declared parameter was not assignable to the implementation's. The
union in PatternPrimitiveUnionPartial makes the `_` arm demand
`_ ∈ keyof P`, which the exhaustive arm cannot prove.

Intersect the inference hook Simplify<P> with the implementation's
parameter shape, R pinned to PatternReturns<P>, so the assignment
type-checks while callers keep inferring P from the argument.

Add a spec for the exhaustive (no `_`) pattern.
2026-09-16 19:10:40 +00:00
tmu 2ff67801e0 ✨ Add VS Code node:test debugging and runner config
launch.json: 'Debug current test file' F5 config running
node --test --strip-types on the active file.

settings.json: register .ts with the connor4312.nodejs-testing
extension via nodejs-testing.extensions, using --strip-types (the
repo pins node >=26 with native type stripping) instead of tsx, so
the extension discovers src/*.test.ts and shows Run/Debug lenses
above test() calls in the native Testing UI.

extensions.json: recommend connor4312.nodejs-testing; cspell.json:
allow 'connor' for the extension id.

.gitignore: un-ignore .vscode/launch.json so the config is shared.
2026-09-16 17:34:53 +00:00
tmu 62a5599ab3 📝 Require labeled AAA blocks in tests
Rule in CONTRIBUTING.md; decision, why and rejected unlabeled
ordering in development/testing.md.
2026-09-16 16:00:43 +00:00
tmu 34567856a5 ♻️ Label arrange-act-assert blocks in specs
Rework every test body into // Arrange / // Act / // Assert blocks
separated by blank lines: the factory is arranged once, the matcher is
built from it in a single act, and all type/runtime checks sink to the
end. index.test.ts adopts the same shape.

Also migrate off expect-type's deprecated toMatchTypeOf: the checks
are assignability tests, so toExtend is the faithful replacement.
2026-09-16 16:00:41 +00:00
tmu a25ac6d1e2 ✅ Spec primitive union matchers
Type-driven tests: every expectTypeOf pairs an assert, covering the
header matrix (exhaustive vs partial patterns, strict R vs widened
returns, string and numeric keys, _ fallback wiring).

Negative cases use Parameters<typeof factory>[0] assignability because
expect-type's .not.toBeCallableWith collapses to never on these generic
Simplify<>-wrapped signatures.

Same documented expectTypeOf floating-promise false positive as
index.test.ts, so the same file-level disable applies; testing.md
known-issue updated to list both files.
2026-09-16 14:52:30 +00:00
tmu 40bf652b97 ✨ Add primitive union pattern matchers
Curried matchers over string|number literal unions in four variants
(exhaustive/partial x strict/widened return inference), per the header
matrix in src/primitive.ts.

- Adds type-fest for Simplify/ValueOf.
- Deliberate oxlint escape hatches (as any dispatch) until a cast-free
  formulation lands; see the file comments.
2026-09-16 14:52:21 +00:00
16 changed files with 582 additions and 10 deletions

No files matched your search

+2 -1
View File
@@ -10,5 +10,6 @@ coverage
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json
.idea .idea
.DS_Store .DS_Store
+2
View File
@@ -13,6 +13,8 @@
"eslint/no-undefined": "off", "eslint/no-undefined": "off",
"eslint/sort-keys": "off", "eslint/sort-keys": "off",
"eslint/id-length": "off", "eslint/id-length": "off",
"eslint/capitalized-comments": "off",
"eslint/no-ternary": "off",
"import/no-named-export": "off", "import/no-named-export": "off",
"eslint/one-var": "off", "eslint/one-var": "off",
"import/group-exports": "off", "import/group-exports": "off",
+1
View File
@@ -1,5 +1,6 @@
{ {
"recommendations": [ "recommendations": [
"connor4312.nodejs-testing",
"oxc.oxc-vscode", "oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview", "typescriptteam.native-preview",
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug current test file",
"runtimeExecutable": "node",
"runtimeArgs": ["--test", "--strip-types"],
"args": ["${file}"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+10
View File
@@ -1,4 +1,14 @@
{ {
"nodejs-testing.extensions": [
{
"extensions": ["mjs", "cjs", "js"],
"parameters": []
},
{
"extensions": ["ts"],
"parameters": ["--strip-types"]
}
],
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- allow ternaries and lowercase comments in oxlint
- switch `src/primitive.ts` prose from block comments to line comments
## [0.1.8] - 2026-09-16 ## [0.1.8] - 2026-09-16
- upgrade dependencies - upgrade dependencies
+6
View File
@@ -67,6 +67,12 @@ before the implementation. The loop is **type → red → green → refactor**:
`npm run verify` as the definition-of-done gate. `npm run verify` as the definition-of-done gate.
Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together.
Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks
separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher
factory), `// Act` exercises the subject once from them (not a second
throwaway call), `// Assert` holds every check — type expectations first,
runtime assertions last; an empty block drops its label (see
[development/testing.md § AAA ordering](./development/testing.md#aaa-ordering)).
Type-first is enforced structurally: `npm test` runs `check:tsc` before the Type-first is enforced structurally: `npm test` runs `check:tsc` before the
test runner, so a wrong type can never be papered over by a passing assertion. test runner, so a wrong type can never be papered over by a passing assertion.
Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the
+6
View File
@@ -6,6 +6,10 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
Setup: 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 @high
✔ 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
v1.0: v1.0:
☐ API surface is stable and fully typed ☐ API surface is stable and fully typed
@@ -20,6 +24,8 @@ v1.0:
Bugs: Bugs:
Enhancements: Enhancements:
☐ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium
☐ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium
Documentation: Documentation:
☐ Create `examples/` directory with runnable snippets ☐ Create `examples/` directory with runnable snippets
+2 -1
View File
@@ -42,7 +42,8 @@
"bestikk", "bestikk",
"silverwind", "silverwind",
"idris", "idris",
"todotasks" "todotasks",
"connor"
], ],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
} }
+29 -1
View File
@@ -36,10 +36,38 @@ The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are i
build step, and the runner relies on the `.ts` import-extension convention (see build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)). [tooling.md](./tooling.md#source-imports-use-ts-extensions)).
## AAA ordering
The rule is in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
#### Decision (2026-09)
Test bodies read arrange → act → assert: inputs (the factory) set up first, the
subject exercised once from them, all checks last — types then runtime. The
blocks are labeled with `// Arrange` / `// Act` / `// Assert` comments and
separated by a blank line; an empty block drops its label.
#### Why
- Interleaved setup/checks hide what runs vs. what is observed; the eye
re-reads the block to find the seams.
- A factory built mid-test invites a second throwaway call of the subject;
arranging it once makes the positive construction and the negative
`Parameters<…>` check share one source of truth.
- Labels make the seams explicit, not inferred — grep-able and reviewable
without reading the statements.
#### Rejected
- Unlabeled ordering (bare blank lines): the seams still have to be found by
reading; the labels cost nothing.
## Known issues ## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
`src/index.test.ts` carries a file-level `oxlint-disable test files that use it (`src/index.test.ts`, `src/primitive.test.ts`) carry a
file-level `oxlint-disable
typescript/no-floating-promises` with an explanatory comment. It is a known typescript/no-floating-promises` with an explanatory comment. It is a known
false positive, not a rule worth disabling project-wide (see false positive, not a rule worth disabling project-wide (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+28 -5
View File
@@ -104,25 +104,48 @@ Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
#### Decision (2026-09) #### Decision (2026-09)
Known type-aware false positives are silenced with source-level `oxlint-disable` A type-aware rule that false-positives is silenced with a source-level
directives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), not with `oxlint-disable` directive (see `src/pattern.ts`, `src/match.ts`,
rules disabled in `.oxlintrc.json`. `src/index.test.ts`), not by turning the rule off in `.oxlintrc.json`.
#### Why #### Why
- The disable sits next to the code it silences, visible to anyone reading the - The disable sits next to the code it silences, visible to anyone reading the
source. source.
- The rule stays on everywhere else, so only the mis-firing line is exempted.
#### Rejected #### Rejected
- A project-wide disable in `.oxlintrc.json`: it hides the suppression from the - A project-wide disable in `.oxlintrc.json` for a false positive: it hides the
reader of the affected code. exemption from the reader of the affected code and switches the rule off
repo-wide for a one-site problem.
#### Known issue #### Known issue
- A source-level disable is a _human_ last resort. AI agents must not add one; - A source-level disable is a _human_ last resort. AI agents must not add one;
they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)). they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)).
### Unwanted stylistic rules are turned off in the config
#### Decision (2026-09)
A stylistic rule the project rejects is `"off"` in the `.oxlintrc.json` `rules`
map, not silenced at a use site. Current entries: `eslint/capitalized-comments`
(comments may start lowercase) and `eslint/no-ternary` (ternaries are allowed),
joining the oxfmt-superseded rules already off.
#### Why
- The rule is wrong for the whole project, not mis-firing at one site, so there
is no line to annotate.
- Keeping the two mechanisms separate keeps a source-level `oxlint-disable`
meaningful: it marks a lone exception.
#### Rejected
- A source-level `oxlint-disable` per use: the same exemption repeated at every
site, and oxfmt can move the site.
### `check:tsc` runs first ### `check:tsc` runs first
#### Decision (2026-09) #### Decision (2026-09)
+30
View File
@@ -8,6 +8,9 @@
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.1.8", "version": "0.1.8",
"license": "MIT", "license": "MIT",
"dependencies": {
"type-fest": "^5.9.0"
},
"devDependencies": { "devDependencies": {
"@arethetypeswrong/cli": "^0.18.5", "@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1", "@runwisp/pubv": "^1.5.1",
@@ -4622,6 +4625,18 @@
"url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
} }
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
@@ -4705,6 +4720,21 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+3
View File
@@ -65,6 +65,9 @@
"setup": "npm run setup:git-commit-message", "setup": "npm run setup:git-commit-message",
"setup:git-commit-message": "git config commit.template commit-message-template" "setup:git-commit-message": "git config commit.template commit-message-template"
}, },
"dependencies": {
"type-fest": "^5.9.0"
},
"devDependencies": { "devDependencies": {
"@arethetypeswrong/cli": "^0.18.5", "@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1", "@runwisp/pubv": "^1.5.1",
+17 -2
View File
@@ -7,44 +7,59 @@ import { expectTypeOf } from "expect-type";
import { type Matcher, P, match } from "./index.ts"; import { type Matcher, P, match } from "./index.ts";
test("match returns a builder", () => { test("match returns a builder", () => {
// Act
const builder = match("x"); const builder = match("x");
// Assert
expectTypeOf(builder).toHaveProperty("with"); expectTypeOf(builder).toHaveProperty("with");
expectTypeOf(builder).toHaveProperty("exhaustive"); expectTypeOf(builder).toHaveProperty("exhaustive");
expectTypeOf(builder).toHaveProperty("otherwise"); expectTypeOf(builder).toHaveProperty("otherwise");
}); });
test("P.literal narrows to its literal type", () => { test("P.literal narrows to its literal type", () => {
// Act
const matcher = P.literal("yes"); const matcher = P.literal("yes");
expectTypeOf(matcher).toMatchTypeOf<Matcher<"yes">>();
// Assert
expectTypeOf(matcher).toExtend<Matcher<"yes">>();
assert.equal(matcher.matches("yes"), true); assert.equal(matcher.matches("yes"), true);
assert.equal(matcher.matches("no"), false); assert.equal(matcher.matches("no"), false);
}); });
test("P.type narrows to the typeof target", () => { test("P.type narrows to the typeof target", () => {
// Act
const matcher = P.type<string>("string"); const matcher = P.type<string>("string");
expectTypeOf(matcher).toMatchTypeOf<Matcher<string>>();
// Assert
expectTypeOf(matcher).toExtend<Matcher<string>>();
assert.equal(matcher.matches("hi"), true); assert.equal(matcher.matches("hi"), true);
assert.equal(matcher.matches(42), false); assert.equal(matcher.matches(42), false);
}); });
test("exhaustive() returns the union of handler return types", () => { test("exhaustive() returns the union of handler return types", () => {
// Act
const result = match<"a" | "b">("a") const result = match<"a" | "b">("a")
.with(P.literal("a"), () => 1 as const) .with(P.literal("a"), () => 1 as const)
.with(P.literal("b"), () => "two" as const) .with(P.literal("b"), () => "two" as const)
.exhaustive(); .exhaustive();
// Assert
expectTypeOf(result).toEqualTypeOf<1 | "two">(); expectTypeOf(result).toEqualTypeOf<1 | "two">();
assert.equal(result, 1); assert.equal(result, 1);
}); });
test("otherwise() falls back when no case matches", () => { test("otherwise() falls back when no case matches", () => {
// Act
const result = match<"x" | "y" | "z">("z") const result = match<"x" | "y" | "z">("z")
.with(P.literal("x"), (v): string => `got ${v}`) .with(P.literal("x"), (v): string => `got ${v}`)
.otherwise((v): string => `fallback ${v}`); .otherwise((v): string => `fallback ${v}`);
// Assert
assert.equal(result, "fallback z"); assert.equal(result, "fallback z");
}); });
test("exhaustive throws when no case matches", () => { test("exhaustive throws when no case matches", () => {
// Assert
assert.throws( assert.throws(
() => () =>
match<"a" | "b" | "c">("c") match<"a" | "b" | "c">("c")
+357
View File
@@ -0,0 +1,357 @@
/* 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 {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherPartial,
getPrimitiveUnionMatcherPartialW,
getPrimitiveUnionMatcherW,
} from "./primitive.ts";
// ============================================================================
// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcherW requires every literal key", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<"a" | "b">();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1 as const;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return "two" as const;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => 1 | "two">();
// A pattern missing a key must not satisfy the parameter type.
expectTypeOf<{
a: () => number;
}>().not.toExtend<Parameters<typeof factory>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), "two");
});
test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<1 | 2>();
// Act
const matcher = factory({
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return n + 1;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
return n * 10;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: 1 | 2) => number>();
assert.equal(matcher(1), 2);
assert.equal(matcher(2), 20);
});
test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A" as const;
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return "B" as const;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10 as const;
},
2: (n) => {
expectTypeOf(n).toEqualTypeOf<2>();
return 20 as const;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "a" | "b" | 1 | 2) => "A" | "B" | 10 | 20
>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "B");
assert.equal(matcher(1), 10);
assert.equal(matcher(2), 20);
});
// ============================================================================
// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<"a" | "b">();
// Act
const matcher = factory({
a: (s): number => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
b: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
});
test("getPrimitiveUnionMatcher handlers receive the matched literal", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<"on" | "off">();
// Act
const matcher = factory({
on: (s) => {
expectTypeOf(s).toEqualTypeOf<"on">();
return `handler ${s}`;
},
off: (s) => {
expectTypeOf(s).toEqualTypeOf<"off">();
return `handler ${s}`;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "on" | "off") => string>();
assert.equal(matcher("on"), "handler on");
assert.equal(matcher("off"), "handler off");
});
test("getPrimitiveUnionMatcher infers one return type across mixed string and numeric keys", () => {
// Arrange
const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s): number => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
b: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"b">();
return 2;
},
1: (n): number => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10;
},
2: (n): number => {
expectTypeOf(n).toEqualTypeOf<2>();
return 20;
},
});
// Assert
// ✔️ ReturnsStrict: R is the best common return type, not a widening union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher(1), 10);
assert.equal(matcher(2), 20);
});
// ============================================================================
// API: getPrimitiveUnionMatcherPartial — ❌ Exhaustive / ✔️ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">();
// Two keys, so `{ a }` lacks only the `_` fallback, nothing else.
const sparseFactory = getPrimitiveUnionMatcherPartial<"a" | "b">();
// Act
const matcher = factory({
a: (s): 1 | 2 => {
expectTypeOf(s).toEqualTypeOf<"a">();
return 1;
},
_: (s): 1 | 2 => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">();
return 2;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>();
// ❌ Exhaustive: gaps are allowed, but only with a `_` fallback.
expectTypeOf<{
a: () => number;
}>().not.toExtend<Parameters<typeof sparseFactory>[0]>();
assert.equal(matcher("a"), 1);
assert.equal(matcher("b"), 2);
assert.equal(matcher("c"), 2);
});
test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartial<"a" | "b">();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A";
},
b: (s) => {
expectTypeOf(s).toEqualTypeOf<"b">();
return "B";
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "B");
});
test("getPrimitiveUnionMatcherPartial routes mixed string and numeric keys, gaps go to _", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s): string => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A";
},
1: (n): string => {
expectTypeOf(n).toEqualTypeOf<1>();
return "one";
},
_: (s): string => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
return "fallback";
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher(1), "one");
assert.equal(matcher(2), "fallback");
});
// ============================================================================
// API: getPrimitiveUnionMatcherPartialW — ❌ Exhaustive / ❌ ReturnsStrict
// ============================================================================
test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of handler returns", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">();
// Two keys, so `{ x }` lacks only the `_` fallback, nothing else.
const sparseFactory = getPrimitiveUnionMatcherPartialW<"x" | "y">();
// Act
const matcher = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
return 1 as const;
},
_: (s) => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"x" | "y" | "z">();
return "fallback" as const;
},
});
// Assert
expectTypeOf(matcher).toEqualTypeOf<
(shape: "x" | "y" | "z") => 1 | "fallback"
>();
// ❌ Exhaustive: gaps are allowed, but only with a `_` fallback.
expectTypeOf<{
x: () => number;
}>().not.toExtend<Parameters<typeof sparseFactory>[0]>();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "fallback");
assert.equal(matcher("z"), "fallback");
});
test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key returns to their union", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartialW<"a" | "b" | 1 | 2>();
// Act
const matcher = factory({
a: (s) => {
expectTypeOf(s).toEqualTypeOf<"a">();
return "A" as const;
},
1: (n) => {
expectTypeOf(n).toEqualTypeOf<1>();
return 10 as const;
},
_: (s) => {
// The fallback sees the whole union, not a single literal.
expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>();
return "fallback" as const;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<
(shape: "a" | "b" | 1 | 2) => "A" | 10 | "fallback"
>();
assert.equal(matcher("a"), "A");
assert.equal(matcher("b"), "fallback");
assert.equal(matcher(1), 10);
assert.equal(matcher(2), "fallback");
});
test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => {
// Arrange
const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">();
// Act
const matcher = factory({
x: (s) => {
expectTypeOf(s).toEqualTypeOf<"x">();
return 1 as const;
},
y: (s) => {
expectTypeOf(s).toEqualTypeOf<"y">();
return "two" as const;
},
});
// Assert
// ❌ ReturnsStrict: mixed handler returns widen to their union.
expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">();
assert.equal(matcher("x"), 1);
assert.equal(matcher("y"), "two");
});
+71
View File
@@ -0,0 +1,71 @@
import type { Simplify, ValueOf } from "type-fest";
type UnaryFn<T, R> = (shape: T) => R;
// ============================================================================
// ✔️ Exhaustive
// ❌ ReturnsStrict
// ============================================================================
type PatternPrimitiveUnion<R, T extends string | number> = {
[K in T]: UnaryFn<K, R>;
};
type PatternReturns<
P extends Record<string | number, UnaryFn<never, unknown>>,
> = ReturnType<ValueOf<P>>;
export const getPrimitiveUnionMatcherW: <T extends string | number>() => <
P extends PatternPrimitiveUnion<unknown, T>,
>(
pattern: Simplify<P>,
) => UnaryFn<T, PatternReturns<P>> = () => (pattern) => (shape) =>
// Rewrite not to use any is possible, was evaluated and solutions were
// more complex than the current solution.
// oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion
(pattern[shape] as any)(shape);
// ============================================================================
// ✔️ Exhaustive
// ✔️ ReturnsStrict
// ============================================================================
export const getPrimitiveUnionMatcher: <T extends string | number>() => <R>(
pattern: Simplify<PatternPrimitiveUnion<R, T>>,
) => UnaryFn<T, R> = getPrimitiveUnionMatcherW;
// ============================================================================
// ❌ Exhaustive
// ✔️ ReturnsStrict
// ============================================================================
type PatternPrimitiveUnionPartial<R, T extends string | number> =
| PatternPrimitiveUnion<R, T>
| (Partial<PatternPrimitiveUnion<R, T>> & {
_: UnaryFn<T, R>;
});
export const getPrimitiveUnionMatcherPartial: <T extends string | number>() => <
R,
>(
pattern: Simplify<PatternPrimitiveUnionPartial<R, T>>,
) => UnaryFn<T, R> = () => (pattern) => (shape) =>
// Rewrite not to use any is possible, was evaluated and solutions were
// more complex than the current solution.
// oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion
(pattern[shape] ?? (pattern as any)["_"])(shape);
// ============================================================================
// ❌ Exhaustive
// ❌ ReturnsStrict
// ============================================================================
export const getPrimitiveUnionMatcherPartialW: <
T extends string | number,
>() => <P extends PatternPrimitiveUnionPartial<unknown, T>>(
// `Simplify<P>` is the inference hook: callers infer `P` from the argument.
// the second half pins the impl parameter's `R` to `PatternReturns<P>`.
// that makes the `= getPrimitiveUnionMatcherPartial` assignment type-check.
// neither half works alone.
// without the witness the union's `_` arm demands `_ ∈ keyof P`.
// without `Simplify<P>` the parameter types do not compare.
pattern: Simplify<P> & PatternPrimitiveUnionPartial<PatternReturns<P>, T>,
) => UnaryFn<T, PatternReturns<P>> = getPrimitiveUnionMatcherPartial;