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', {