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