From e5df17310e94ea7b110ca094bfb199cf5cce1e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 3 Sep 2026 10:39:15 +0000 Subject: [PATCH] :white_check_mark: Add expect-type for type-level tests - Install expect-type@1.4.0 (devDependency) - New src/index.test.ts exercises the public API with both runtime assertions (node:assert/strict inside node:test) and type-level assertions (expectTypeOf().toEqualTypeOf, toMatchTypeOf, toHaveProperty) - Tests are excluded from tsc -p tsconfig.build.json (no .d.ts pollution in dist/) - Type-level checks happen during tsc --noEmit (run by 'npm run check' and the test script); expectTypeOf assertions have no runtime side effect, so they pair naturally with node --test in the same test() blocks - cspell: add EDITMSG to dictionary (used in commit-message-template path) - oxlint test-file overrides: silence no-nodejs-modules (we use node: builtins) and no-magic-numbers (literals in tests are fine) Coverage: - match() returns a builder with with/exhaustive/otherwise - P.literal narrows to its literal type, P.type to its typeof target - exhaustive() returns the union of handler return types - otherwise() falls back when no case matches - exhaustive() throws when no case matches --- .oxlintrc.json | 4 +++- cspell.json | 3 ++- package-lock.json | 10 +++++++++ package.json | 1 + src/index.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/index.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index a6c12d3..96c7792 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,7 +30,9 @@ "files": ["**/*.test.ts"], "rules": { "no-unused-expressions": "off", - "no-empty-file": "off" + "no-empty-file": "off", + "import/no-nodejs-modules": "off", + "eslint/no-magic-numbers": "off" } } ], diff --git a/cspell.json b/cspell.json index 383a13e..6c98a90 100644 --- a/cspell.json +++ b/cspell.json @@ -12,7 +12,8 @@ "nocheck", "oxfmtrc", "oxlintrc", - "sortpackagerc" + "sortpackagerc", + "EDITMSG" ], "ignorePaths": ["dist", "node_modules", "public", "coverage", "*.svg"] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b38f662..4778f27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "c8": "^10.1.3", "check-outdated": "^2.13.0", "cspell": "^8.19.3", + "expect-type": "1.4.0", "lefthook": "^1.11.12", "oxfmt": "^0.66.0", "oxlint": "^1.81.0", @@ -2277,6 +2278,15 @@ "node": ">=4" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-equals": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", diff --git a/package.json b/package.json index 71e0f20..96b22b9 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "c8": "^10.1.3", "check-outdated": "^2.13.0", "cspell": "^8.19.3", + "expect-type": "1.4.0", "lefthook": "^1.11.12", "oxfmt": "^0.66.0", "oxlint": "^1.81.0", diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..24b6a2e --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { expectTypeOf } from "expect-type"; +import { match, P, type Matcher } from "./index.js"; + +test("match returns a builder", () => { + const builder = match("x"); + expectTypeOf(builder).toHaveProperty("with"); + expectTypeOf(builder).toHaveProperty("exhaustive"); + expectTypeOf(builder).toHaveProperty("otherwise"); +}); + +test("P.literal narrows to its literal type", () => { + const matcher = P.literal("yes"); + expectTypeOf(matcher).toMatchTypeOf>(); + assert.equal(matcher.matches("yes"), true); + assert.equal(matcher.matches("no"), false); +}); + +test("P.type narrows to the typeof target", () => { + const matcher = P.type("string"); + expectTypeOf(matcher).toMatchTypeOf>(); + assert.equal(matcher.matches("hi"), true); + assert.equal(matcher.matches(42), false); +}); + +test("exhaustive() returns the union of handler return types", () => { + const result = match<"a" | "b">("a" as "a" | "b") + .with(P.literal("a"), () => 1 as const) + .with(P.literal("b"), () => "two" as const) + .exhaustive(); + + expectTypeOf(result).toEqualTypeOf<1 | "two">(); + assert.equal(result, 1); +}); + +test("otherwise() falls back when no case matches", () => { + const result = match<"x" | "y" | "z">("z" as "x" | "y" | "z") + .with(P.literal("x"), (v): string => `got ${v}`) + .otherwise((v): string => `fallback ${v}`); + assert.equal(result, "fallback z"); +}); + +test("exhaustive throws when no case matches", () => { + assert.throws( + () => + match<"a" | "b" | "c">("c" as "a" | "b" | "c") + .with(P.literal("a"), () => "A") + .with(P.literal("b"), () => "B") + .exhaustive(), + /no matching case/, + ); +});