From 8322547fd7bf8db9eb5b0c6fdec5a8b929e096c1 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Wed, 29 Jul 2026 17:23:57 -0700 Subject: [PATCH] fix(patterns): preserve literal inference in compound matchers Pattern combinators previously inferred their generic arguments through broad Pattern arrays and CopyRecord constraints. TypeScript therefore widened object literal discriminants before TypeFromPattern could inspect them, which propagated broad method parameters through interface guards and makeExo. Use const type parameters for M.or, M.and, M.splitArray, and M.splitRecord, including their optional and rest pattern parameters, so literal unions, record fields, and tuples survive inference. TypeFromPattern removes only the readonly modifier introduced by const object inference when producing matched record values, preserving the existing mutable public result shape. CastedPattern remains useful for unverifiable branded or cross-field claims, but it is an unchecked escape hatch rather than the remedy for literals that the API can preserve soundly. The focused type regressions cover raw literal unions, discriminants in splitRecord, literal tuples and rest patterns in splitArray, intersections through M.and, and a nested interface method guard. The change is declaration-only and does not alter runtime matching or compatibility for callers that do not rely on widened inferred types. --- packages/patterns/src/type-from-pattern.ts | 16 +++- packages/patterns/src/types.ts | 16 ++-- packages/patterns/test/types.test-d.ts | 85 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/packages/patterns/src/type-from-pattern.ts b/packages/patterns/src/type-from-pattern.ts index 2e83ad5435..09e491d242 100644 --- a/packages/patterns/src/type-from-pattern.ts +++ b/packages/patterns/src/type-from-pattern.ts @@ -60,7 +60,10 @@ type TFStructuralPattern

= : P extends readonly [infer H, ...infer T] ? [TypeFromPattern, ...TFTuple] : P extends CopyRecord - ? Simplify<{ [K in keyof P]: TypeFromPattern }> + ? // Const type parameters preserve object literals as readonly, but + // TypeFromPattern describes matched values using the existing mutable + // record shape. + Simplify<{ -readonly [K in keyof P]: TypeFromPattern }> : P; // ===== Internal helpers ===== @@ -232,13 +235,18 @@ type TFAnd = T extends readonly [infer H, ...infer R] : TypeFromPattern : unknown; -/** Infer a split record: required fields + optional fields + rest (index signature). */ +/** + * Infer a split record: required fields + optional fields + rest (index + * signature). + * Const type parameters preserve object literals as readonly, but matched + * record values retain the existing mutable shape. + */ type TFSplitRecord = Simplify< (Req extends CopyRecord - ? { [K in keyof Req]: TypeFromPattern } + ? { -readonly [K in keyof Req]: TypeFromPattern } : {}) & (Opt extends CopyRecord - ? { [K in keyof Opt]?: TypeFromPattern } + ? { -readonly [K in keyof Opt]?: TypeFromPattern } : {}) & // When the rest arg is the empty-record pattern `{}` // (i.e. "refuse unsupported options"), don't emit an index diff --git a/packages/patterns/src/types.ts b/packages/patterns/src/types.ts index 6532174c12..7f0f0a51d7 100644 --- a/packages/patterns/src/types.ts +++ b/packages/patterns/src/types.ts @@ -277,13 +277,13 @@ export type PatternMatchers = { /** * Matches against the intersection of all sub-Patterns. */ - and:

(...subPatts: P) => MatcherOf<'and', P>; + and: (...subPatts: P) => MatcherOf<'and', P>; /** * Matches against the union of all sub-Patterns * (requiring a successful match against at least one). */ - or:

(...subPatts: P) => MatcherOf<'or', P>; + or: (...subPatts: P) => MatcherOf<'or', P>; /** * Matches against the negation of the sub-Pattern. @@ -562,9 +562,9 @@ export type PatternMatchers = { * are collected and matched against `rest`. */ splitArray: < - Req extends Pattern[] = Pattern[], // widest: any patterns (not [] — that would mean "no required") - Opt extends Pattern[] = [], // narrowest: no optional elements when omitted - Rest extends Pattern = never, // narrowest: no rest matching when omitted + const Req extends Pattern[] = Pattern[], // widest: any patterns (not [] — that would mean "no required") + const Opt extends Pattern[] = [], // narrowest: no optional elements when omitted + const Rest extends Pattern = never, // narrowest: no rest matching when omitted >( required: [...Req], optional?: [...Opt], @@ -586,9 +586,9 @@ export type PatternMatchers = { * but may omit properties that appear on `optional`. */ splitRecord: < - Req extends CopyRecord = CopyRecord, - Opt extends CopyRecord = {}, - Rest extends Pattern = never, + const Req extends CopyRecord = CopyRecord, + const Opt extends CopyRecord = {}, + const Rest extends Pattern = never, >( required: Req, optional?: Opt, diff --git a/packages/patterns/test/types.test-d.ts b/packages/patterns/test/types.test-d.ts index dc53d75fce..c1ba7e099d 100644 --- a/packages/patterns/test/types.test-d.ts +++ b/packages/patterns/test/types.test-d.ts @@ -309,6 +309,20 @@ expectType(null as unknown as TypeFromPattern); // ===== 4. Combinators: or → union, and → intersection, opt, eref ===== +// M.or() preserves literal arguments as a literal union. +{ + const p = M.or('start', 'continue', 'abort'); + type T = TypeFromPattern; + expectType<'start' | 'continue' | 'abort'>(null as unknown as T); +} + +// M.or() also preserves literal discriminants in record patterns. +{ + const p = M.or({ mode: 'start' }, { mode: 'continue' }); + type T = TypeFromPattern; + expectType<{ mode: 'start' } | { mode: 'continue' }>(null as unknown as T); +} + // M.or() → union { const p = M.or(M.string(), M.nat()); @@ -323,6 +337,13 @@ expectType(null as unknown as TypeFromPattern); expectType(null as unknown as T); } +// M.and() preserves literal fields in intersected record patterns. +{ + const p = M.and({ mode: 'start' }, { payload: M.string() }); + type T = TypeFromPattern; + expectType<{ mode: 'start' } & { payload: string }>(null as unknown as T); +} + // M.opt() → T | void (void rather than undefined; see TFKindMap comment) { const p = M.opt(M.string()); @@ -383,6 +404,19 @@ expectType(null as unknown as TypeFromPattern); }>(null as unknown as T); } +// Literal required and optional fields remain narrow. +{ + const p = M.splitRecord( + { mode: M.or('start', 'continue') }, + { phase: 'ready' }, + ); + type T = TypeFromPattern; + expectType<{ + mode: 'start' | 'continue'; + phase?: 'ready' | undefined; + }>(null as unknown as T); +} + // ===== 7. splitArray: required only, required + optional ===== // Required only @@ -402,6 +436,28 @@ expectType(null as unknown as TypeFromPattern); expectType<[string, bigint?, boolean?]>(null as unknown as T); } +// Literal elements remain narrow while preserving the tuple shape. +{ + const p = M.splitArray([{ mode: 'start' }, { mode: 'continue' }], ['done']); + type T = TypeFromPattern; + expectType<[{ mode: 'start' }, { mode: 'continue' }, 'done'?]>( + null as unknown as T, + ); +} + +// Literal rest patterns remain narrow as well. +{ + const p = M.splitArray([], [], { mode: 'rest' }); + type T = TypeFromPattern; + expectType<{ mode: 'rest' }[]>(null as unknown as T); +} + +{ + const p = M.splitRecord({}, {}, { mode: 'rest' }); + type T = TypeFromPattern; + expectType<{ [key: string]: { mode: 'rest' } }>(null as unknown as T); +} + // ===== 8. Hint parameters (type narrowing) ===== // M.string<`${bigint}`>() → `${bigint}` @@ -555,6 +611,35 @@ expectType(null as unknown as TypeFromPattern); expectType<{ bar: (arg0: string) => bigint }>(null as unknown as Methods); } +// A nested interface method guard keeps literal discriminants narrow. +{ + type Operation = { mode: 'start' } | { mode: 'continue' | 'abort' | 'skip' }; + const OperationShape = M.or( + M.splitRecord({ mode: 'start' }), + M.splitRecord({ mode: M.or('continue', 'abort', 'skip') }), + ); + expectType( + null as unknown as TypeFromPattern, + ); + + const ControllerI = M.interface('Controller', { + handle: M.call(OperationShape).returns(M.boolean()), + }); + type ControllerMethods = TypeFromInterfaceGuard; + expectType<{ handle: (arg0: Operation) => boolean }>( + null as unknown as ControllerMethods, + ); + + const methods: ControllerMethods = { + handle(operation) { + expectType(operation); + return operation.mode === 'start'; + }, + }; + // eslint-disable-next-line no-void + void methods; +} + // Multi-method interface { const CounterI = M.interface('Counter', {