The fallback overload could not infer `R` from the handlers: `R` appeared only inside the `Exact` constraint, which is not an inference site, so with inferred handler params it collapsed to `unknown` and the matcher silently returned `UnaryFn<T, unknown>`. Adding `Partial<Handlers<T, R>>` to the handler parameter gives `R` an inference site, so the common return is inferred from the handlers and the fallback alike. Tests drop their explicit handler return annotations: real handlers are short and unannotated, and the common type is now inferred from them.
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
import type { Exact, ValueOf } from "type-fest";
|
|
|
|
type UnaryFn<T, R> = (shape: T) => R;
|
|
|
|
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
|
|
// when `P`'s constraint has optional keys.
|
|
type PatternReturns<P> = ReturnType<
|
|
Extract<ValueOf<P>, (...args: never[]) => unknown>
|
|
>;
|
|
|
|
type Handlers<T extends string | number, R> = { [K in T]: UnaryFn<K, R> };
|
|
|
|
// The fallback is a *second argument*, not a property of the handler map,
|
|
// because its parameter is the remainder `Exclude<T, keyof Handled>` and TypeScript
|
|
// fixes a property's contextual type before it infers its sibling keys. A later
|
|
// argument, by contrast, is contextually typed from inference on an earlier
|
|
// one, so the split is what makes the remainder expressible at all.
|
|
// See development/library.md.
|
|
type Fallback<T extends string | number, Handled, R> = UnaryFn<
|
|
Exclude<T, keyof Handled>,
|
|
R
|
|
>;
|
|
|
|
// TypeScript does not apply the excess-property check to a generic constraint,
|
|
// so `Exact` restores it for the generic forms: a handler map can otherwise
|
|
// carry keys outside `T`.
|
|
|
|
// oxlint-disable typescript/unified-signatures
|
|
// Strict returns: one common `R`. Overload order is load-bearing:
|
|
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
|
|
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
|
|
interface MatcherStrict<T extends string | number> {
|
|
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
|
|
<R, Handled extends Exact<Partial<Handlers<T, R>>, Handled>>(
|
|
handlers: Handled & Partial<Handlers<T, R>>,
|
|
fallback: Fallback<T, Handled, R>,
|
|
): UnaryFn<T, R>;
|
|
}
|
|
// oxlint-enable typescript/unified-signatures
|
|
|
|
// Widened returns: the union of every handler's return type. `P` is inferred
|
|
// from the whole handler map, whose closed constraint supplies the
|
|
// contextual/autocomplete type.
|
|
// oxlint-disable typescript/unified-signatures
|
|
interface MatcherWidening<T extends string | number> {
|
|
<P extends Exact<Handlers<T, unknown>, P>>(
|
|
handlers: P,
|
|
): UnaryFn<T, PatternReturns<P>>;
|
|
<R, Handled extends Exact<Partial<Handlers<T, unknown>>, Handled>>(
|
|
handlers: Handled,
|
|
fallback: Fallback<T, Handled, R>,
|
|
): UnaryFn<T, PatternReturns<Handled> | R>;
|
|
}
|
|
// oxlint-enable typescript/unified-signatures
|
|
|
|
type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
|
|
|
|
const dispatch =
|
|
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
|
|
(shape: string | number): unknown =>
|
|
(
|
|
handlers[shape] ??
|
|
fallback ??
|
|
(() => {
|
|
throw new Error(`Unhandled shape: ${shape}`);
|
|
})
|
|
)(
|
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
|
shape as never,
|
|
);
|
|
|
|
export const getMatcher = <T extends string | number>(): MatcherStrict<T> =>
|
|
dispatch;
|
|
export const getMatcherW = <T extends string | number>(): MatcherWidening<T> =>
|
|
dispatch;
|