diff --git a/.oxlintrc.json b/.oxlintrc.json index 055de8a..a6c12d3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,7 +14,12 @@ "restriction": "error", "nursery": "off" }, - "rules": {}, + "rules": { + "eslint/no-undefined": "off", + "eslint/sort-keys": "off", + "eslint/id-length": "off", + "import/no-named-export": "off" + }, "env": { "builtin": true, "es2024": true, diff --git a/src/index.ts b/src/index.ts index cb0ff5c..970ea6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export {}; +export { match, P } from "./match.js"; +export type { Matcher, Pattern } from "./pattern.js"; diff --git a/src/match.ts b/src/match.ts new file mode 100644 index 0000000..6487acb --- /dev/null +++ b/src/match.ts @@ -0,0 +1,63 @@ +import { P, type Matcher, type Pattern } from "./pattern.js"; + +type Cases = ReadonlyArray< + readonly [Matcher, (value: unknown) => R] +>; + +interface MatchBuilder { + with( + pattern: Matcher, + handler: (value: U) => V, + ): MatchBuilder; + exhaustive(): R; + otherwise(handler: (value: T) => R): R; +} + +const buildMatch = (value: T, cases: Cases): MatchBuilder => { + const apply = (): R | undefined => { + for (const [matcher, handler] of cases) { + if (matcher.matches(value as unknown)) { + return handler(value); + } + } + return undefined; + }; + + const builder = { + with( + pattern: Matcher, + handler: (value: U) => V, + ): MatchBuilder { + const nextCases: Cases = [ + ...cases, + [pattern, handler as (value: unknown) => R | V], + ]; + return buildMatch(value, nextCases); + }, + exhaustive(): R { + const result = apply(); + if (result === undefined) { + throw new Error( + "tiny-pattern-ts: match.exhaustive() called with no matching case", + ); + } + return result; + }, + otherwise(handler: (value: T) => R): R { + for (const [matcher, run] of cases) { + if (matcher.matches(value as unknown)) { + return run(value) as R; + } + } + return handler(value); + }, + }; + + return builder; +}; + +export const match = (value: T): MatchBuilder => + buildMatch(value, []); + +export type { Matcher, Pattern }; +export { P }; diff --git a/src/pattern.ts b/src/pattern.ts new file mode 100644 index 0000000..7261c72 --- /dev/null +++ b/src/pattern.ts @@ -0,0 +1,89 @@ +/** + * Pattern matching primitives. Each constructor returns a lightweight + * matcher object whose `matches` method returns a type guard. + */ + +export interface Matcher { + readonly matches: (value: unknown) => value is T; +} + +const literalMatcher = < + const L extends string | number | boolean | null | undefined, +>( + value: L, +): Matcher => ({ + matches: (candidate): candidate is L => candidate === value, +}); + +const typeMatcher = ( + type: + | "string" + | "number" + | "boolean" + | "bigint" + | "symbol" + | "undefined" + | "object" + | "function", +): Matcher => { + const matches = (value: unknown): value is T => { + if (type === "undefined") return value === undefined; + if (type === "object") + return ( + (typeof value === "object" && value !== null) || + typeof value === "function" + ); + return typeof value === type; + }; + return { matches }; +}; + +const whenMatcher = ( + predicate: (value: unknown) => value is T, +): Matcher => ({ + matches: predicate, +}); + +const whenMatcherAny = ( + predicate: (value: unknown) => boolean, +): Matcher => ({ + matches: (value: unknown): value is T => predicate(value), +}); + +const structuralMatcher = ( + shape: S, + refine?: (value: S) => value is T, +): Matcher => ({ + matches: (value: unknown): value is T => { + if (typeof value !== "object" || value === null) return false; + const candidate = value as S; + for (const key of Object.keys(shape) as Array) { + if (!(key in candidate)) return false; + const expected = shape[key]; + const actual = candidate[key]; + if ( + typeof expected === "object" && + expected !== null && + "matches" in expected + ) { + const nested = expected as unknown as Matcher; + if (!nested.matches(actual)) return false; + } else if (actual !== expected) { + return false; + } + } + return refine + ? refine(candidate) + : (true as T extends S ? true : never); + }, +}); + +export const P = { + literal: literalMatcher, + type: typeMatcher, + when: whenMatcher, + any: whenMatcherAny, + shape: structuralMatcher, +} as const; + +export type Pattern = Matcher;