@doeixd/machine 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/matcher.ts ADDED
@@ -0,0 +1,560 @@
1
+ /**
2
+ * @file Advanced Pattern Matching for State Machines
3
+ * @description
4
+ * Provides type-safe pattern matching utilities for discriminating between machine types.
5
+ * Supports three complementary APIs: type guards, exhaustive pattern matching, and simple matching.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // Define a matcher for class-based machines
10
+ * const match = createMatcher(
11
+ * classCase('idle', IdleMachine),
12
+ * classCase('loading', LoadingMachine),
13
+ * classCase('success', SuccessMachine)
14
+ * );
15
+ *
16
+ * // API 1: Type Guards
17
+ * if (match.is.loading(machine)) {
18
+ * // machine is narrowed to LoadingMachine
19
+ * }
20
+ *
21
+ * // API 2: Exhaustive Pattern Matching
22
+ * const result = match.when(machine).is<string>(
23
+ * match.case.idle(() => 'idle'),
24
+ * match.case.loading(() => 'loading'),
25
+ * match.case.success(m => m.context.data),
26
+ * match.exhaustive
27
+ * );
28
+ *
29
+ * // API 3: Simple Match
30
+ * const name = match(machine); // 'idle' | 'loading' | 'success' | null
31
+ * ```
32
+ */
33
+
34
+ import type { Machine, Context } from './index';
35
+ import { hasState } from './index';
36
+
37
+ // =============================================================================
38
+ // SECTION: CORE TYPES
39
+ // =============================================================================
40
+
41
+ /**
42
+ * A matcher case tuple that defines a state pattern.
43
+ *
44
+ * @template Name - The unique name for this case (used for type guards and pattern matching)
45
+ * @template M - The machine type this case matches
46
+ * @template Pred - The predicate function that determines if a machine matches this case
47
+ */
48
+ export type MatcherCase<
49
+ Name extends string,
50
+ M,
51
+ Pred extends (m: any) => m is M
52
+ > = readonly [
53
+ name: Name,
54
+ machineType: M,
55
+ predicate: Pred
56
+ ];
57
+
58
+ /**
59
+ * Extracts the machine type from a MatcherCase.
60
+ */
61
+ type CaseToMachine<C> = C extends MatcherCase<any, infer M, any> ? M : never;
62
+
63
+ /**
64
+ * Extracts the case name from a MatcherCase.
65
+ */
66
+ type CaseToName<C> = C extends MatcherCase<infer Name, any, any> ? Name : never;
67
+
68
+ /**
69
+ * Builds a mapping from case names to their machine types.
70
+ */
71
+ export type CasesToMapping<Cases extends readonly MatcherCase<any, any, any>[]> = {
72
+ [C in Cases[number]as CaseToName<C>]: CaseToMachine<C>;
73
+ };
74
+
75
+ /**
76
+ * Creates a union of all possible machine types from the cases.
77
+ */
78
+ export type MatcherUnion<Cases extends readonly MatcherCase<any, any, any>[]> =
79
+ Cases[number] extends MatcherCase<any, infer M, any> ? M : never;
80
+
81
+ /**
82
+ * Extracts the union of all case names.
83
+ */
84
+ export type CaseNames<Cases extends readonly MatcherCase<any, any, any>[]> =
85
+ CaseToName<Cases[number]>;
86
+
87
+ /**
88
+ * A branded type representing a case handler in pattern matching.
89
+ * This is used internally to track which cases have been handled.
90
+ */
91
+ export type CaseHandler<Name extends string, M, R> = {
92
+ readonly __brand: 'CaseHandler';
93
+ readonly __name: Name;
94
+ readonly __machine: M;
95
+ readonly __return: R;
96
+ readonly handler: (machine: M) => R;
97
+ };
98
+
99
+ /**
100
+ * Exhaustiveness marker - signals that all cases must be handled.
101
+ */
102
+ export type ExhaustivenessMarker = {
103
+ readonly __exhaustive: true;
104
+ };
105
+
106
+ /**
107
+ * Extracts machine types from an array of case handlers.
108
+ */
109
+ type ExtractHandledMachines<H extends readonly any[]> =
110
+ H extends readonly [infer First, ...infer Rest]
111
+ ? (First extends CaseHandler<any, infer M, any> ? M : never) | ExtractHandledMachines<Rest>
112
+ : never;
113
+
114
+ /**
115
+ * Extracts return types from an array of case handlers.
116
+ */
117
+ type ExtractHandlerReturn<H extends readonly any[]> =
118
+ H extends readonly CaseHandler<any, any, infer R>[] ? R : never;
119
+
120
+ /**
121
+ * Checks if all machine types in Union have been handled.
122
+ * Returns true if exhaustive, otherwise returns an error type with missing cases.
123
+ */
124
+ export type IsExhaustive<Union, Handled> =
125
+ Exclude<Union, Handled> extends never
126
+ ? true
127
+ : {
128
+ readonly __error: 'Non-exhaustive match - missing cases';
129
+ readonly __missing: Exclude<Union, Handled>;
130
+ };
131
+
132
+ /**
133
+ * Pattern matching builder returned by matcher.when().
134
+ */
135
+ export interface WhenBuilder<
136
+ _Cases extends readonly MatcherCase<any, any, any>[],
137
+ M
138
+ > {
139
+ /**
140
+ * Execute pattern matching with exhaustiveness checking.
141
+ *
142
+ * @template R - The return type of all handlers
143
+ * @param handlers - Array of case handlers followed by exhaustiveness marker
144
+ * @returns The result of the matched handler, or compile error if not exhaustive
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * match.when(machine).is<string>(
149
+ * match.case.idle(() => 'idle'),
150
+ * match.case.loading(() => 'loading'),
151
+ * match.exhaustive
152
+ * );
153
+ * ```
154
+ */
155
+ /**
156
+ * Overload 1: Infer return type from handlers (Enables exhaustiveness checking).
157
+ */
158
+ is<H extends readonly CaseHandler<CaseNames<_Cases>, any, any>[]>(
159
+ ...handlers: [...H, ExhaustivenessMarker]
160
+ ): IsExhaustive<M, ExtractHandledMachines<H>> extends true
161
+ ? ExtractHandlerReturn<H>
162
+ : IsExhaustive<M, ExtractHandledMachines<H>>;
163
+
164
+ /**
165
+ * Overload 2: Explicit return type (No exhaustiveness checking).
166
+ */
167
+ is<R>(
168
+ ...handlers: [...CaseHandler<CaseNames<_Cases>, any, R>[], ExhaustivenessMarker]
169
+ ): R;
170
+ }
171
+
172
+ /**
173
+ * The main Matcher interface with three APIs.
174
+ */
175
+ export interface Matcher<Cases extends readonly MatcherCase<any, any, any>[]> {
176
+ /**
177
+ * API 1: Type guard access via dynamic properties.
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * if (match.is.loading(machine)) {
182
+ * // machine is narrowed to LoadingMachine
183
+ * }
184
+ * ```
185
+ */
186
+ readonly is: {
187
+ [Name in CaseNames<Cases>]: (
188
+ machine: any
189
+ ) => machine is CasesToMapping<Cases>[Name];
190
+ };
191
+
192
+ /**
193
+ * API 2a: Pattern matching builder.
194
+ *
195
+ * @example
196
+ * ```typescript
197
+ * match.when(machine).is<string>(
198
+ * match.case.idle(() => 'idle'),
199
+ * match.case.loading(() => 'loading'),
200
+ * match.exhaustive
201
+ * );
202
+ * ```
203
+ */
204
+ when: <M>(
205
+ machine: M
206
+ ) => WhenBuilder<Cases, M>;
207
+
208
+ /**
209
+ * API 2b: Case handler creator for pattern matching.
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * match.case.loading((m) => `Loading: ${m.context.startTime}`)
214
+ * ```
215
+ */
216
+ readonly case: {
217
+ [Name in CaseNames<Cases>]: <R>(
218
+ handler: (machine: CasesToMapping<Cases>[Name]) => R
219
+ ) => CaseHandler<Name, CasesToMapping<Cases>[Name], R>;
220
+ };
221
+
222
+ /**
223
+ * API 2c: Exhaustiveness marker for pattern matching.
224
+ */
225
+ readonly exhaustive: ExhaustivenessMarker;
226
+
227
+ /**
228
+ * API 3: Simple match - returns the name of the matched case or null.
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * const name = match(machine); // 'idle' | 'loading' | 'success' | null
233
+ * ```
234
+ */
235
+ <M>(machine: M): M extends MatcherUnion<Cases>
236
+ ? CaseNames<Cases> | null
237
+ : null;
238
+ }
239
+
240
+ // =============================================================================
241
+ // SECTION: MATCHER CREATION
242
+ // =============================================================================
243
+
244
+ /**
245
+ * Creates a type-safe matcher for discriminating between machine types.
246
+ *
247
+ * @template Cases - Tuple of [name, MachineType, predicate] configurations
248
+ * @param cases - Array of matcher case definitions
249
+ * @returns A matcher object with three APIs: is (type guards), when (pattern matching), and direct call (simple match)
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * // Class-based matching
254
+ * const match = createMatcher(
255
+ * ['idle', IdleMachine, (m): m is IdleMachine => m instanceof IdleMachine],
256
+ * ['loading', LoadingMachine, (m): m is LoadingMachine => m instanceof LoadingMachine]
257
+ * );
258
+ *
259
+ * // Or use helper functions
260
+ * const match = createMatcher(
261
+ * classCase('idle', IdleMachine),
262
+ * classCase('loading', LoadingMachine)
263
+ * );
264
+ * ```
265
+ */
266
+ export function createMatcher<
267
+ const Cases extends readonly MatcherCase<string, any, (m: any) => m is any>[]
268
+ >(
269
+ ...cases: Cases
270
+ ): Matcher<Cases> {
271
+ // Build lookup map for O(1) case access
272
+ const nameToCase = new Map<string, { predicate: (m: any) => boolean }>();
273
+
274
+ for (const [name, _, predicate] of cases) {
275
+ if (nameToCase.has(name)) {
276
+ throw new Error(`Duplicate matcher case name: "${name}"`);
277
+ }
278
+ nameToCase.set(name, { predicate });
279
+ }
280
+
281
+ // API 1: Type Guards (using Proxy for dynamic property access)
282
+ const isProxy = new Proxy({} as any, {
283
+ get(_target, prop: string) {
284
+ return function isGuard(machine: any): machine is any {
285
+ const caseConfig = nameToCase.get(prop);
286
+ if (!caseConfig) {
287
+ const available = Array.from(nameToCase.keys()).join(', ');
288
+ throw new Error(
289
+ `Unknown matcher case: "${prop}". Available cases: ${available}`
290
+ );
291
+ }
292
+ return caseConfig.predicate(machine);
293
+ };
294
+ }
295
+ });
296
+
297
+ // API 2b: Case Handlers (using Proxy for dynamic property access)
298
+ const caseProxy = new Proxy({} as any, {
299
+ get(_target, prop: string) {
300
+ return function createCaseHandler<R>(
301
+ handler: (machine: any) => R
302
+ ): CaseHandler<any, any, R> {
303
+ // Validate case name exists
304
+ if (!nameToCase.has(prop)) {
305
+ const available = Array.from(nameToCase.keys()).join(', ');
306
+ throw new Error(
307
+ `Unknown matcher case: "${prop}". Available cases: ${available}`
308
+ );
309
+ }
310
+
311
+ return {
312
+ __brand: 'CaseHandler' as const,
313
+ __name: prop,
314
+ __machine: undefined as any,
315
+ __return: undefined as any,
316
+ handler
317
+ };
318
+ };
319
+ }
320
+ });
321
+
322
+ // API 2c: Exhaustiveness marker
323
+ const exhaustive: ExhaustivenessMarker = { __exhaustive: true };
324
+
325
+ // API 2a: Pattern Matching Builder
326
+ function when<M>(machine: M): WhenBuilder<Cases, M> {
327
+ return {
328
+ is<R>(...handlers: any[]): R {
329
+ // Validate we have at least exhaustiveness marker
330
+ if (handlers.length === 0) {
331
+ throw new Error('Pattern match requires at least one handler and exhaustiveness marker');
332
+ }
333
+
334
+ // Last element should be exhaustiveness marker
335
+ const lastHandler = handlers[handlers.length - 1];
336
+ if (!lastHandler || typeof lastHandler !== 'object' || !('__exhaustive' in lastHandler)) {
337
+ throw new Error(
338
+ 'Pattern match must end with match.exhaustive for compile-time exhaustiveness checking'
339
+ );
340
+ }
341
+
342
+ // Remove exhaustiveness marker
343
+ const actualHandlers = handlers.slice(0, -1) as CaseHandler<any, any, R>[];
344
+
345
+ // Try each handler in order (first-match-wins)
346
+ for (const caseHandler of actualHandlers) {
347
+ const caseName = caseHandler.__name;
348
+ const caseConfig = nameToCase.get(caseName);
349
+
350
+ if (!caseConfig) {
351
+ throw new Error(`Internal error: Unknown matcher case in handler: ${caseName}`);
352
+ }
353
+
354
+ if (caseConfig.predicate(machine)) {
355
+ return caseHandler.handler(machine);
356
+ }
357
+ }
358
+
359
+ // No handler matched - this means pattern match wasn't actually exhaustive at runtime
360
+ const handledCases = actualHandlers.map(h => h.__name).join(', ');
361
+ const allCases = Array.from(nameToCase.keys()).join(', ');
362
+ throw new Error(
363
+ `Non-exhaustive pattern match at runtime: no handler matched the machine.\n` +
364
+ `Handled cases: [${handledCases}]\n` +
365
+ `All cases: [${allCases}]\n` +
366
+ `This may occur if predicates don't cover all runtime possibilities.`
367
+ );
368
+ }
369
+ };
370
+ }
371
+
372
+ // API 3: Simple Match (callable function)
373
+ function simpleMatcher<M>(machine: M): string | null {
374
+ for (const [name, _, predicate] of cases) {
375
+ if (predicate(machine)) {
376
+ return name;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ // Combine all APIs into a single object
383
+ return Object.assign(simpleMatcher, {
384
+ is: isProxy,
385
+ when,
386
+ case: caseProxy,
387
+ exhaustive
388
+ }) as any;
389
+ }
390
+
391
+ // =============================================================================
392
+ // SECTION: HELPER FUNCTIONS
393
+ // =============================================================================
394
+
395
+ /**
396
+ * Creates a class-based matcher case using instanceof checking.
397
+ * This is the most common pattern for Type-State machines.
398
+ *
399
+ * @template Name - The unique name for this case
400
+ * @template T - The class constructor
401
+ * @param name - The name to use for this case
402
+ * @param machineClass - The class to check with instanceof
403
+ * @returns A matcher case tuple
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * const match = createMatcher(
408
+ * classCase('idle', IdleMachine),
409
+ * classCase('loading', LoadingMachine),
410
+ * classCase('success', SuccessMachine)
411
+ * );
412
+ * ```
413
+ */
414
+ export function classCase<
415
+ Name extends string,
416
+ T extends abstract new (...args: any[]) => any
417
+ >(
418
+ name: Name,
419
+ machineClass: T
420
+ ): MatcherCase<Name, InstanceType<T>, (m: any) => m is InstanceType<T>> {
421
+ return [
422
+ name,
423
+ undefined as any, // Type-only, not used at runtime
424
+ (m): m is InstanceType<T> => m instanceof machineClass
425
+ ] as const;
426
+ }
427
+
428
+ /**
429
+ * Creates a discriminated union matcher case based on a context property.
430
+ * This integrates with the existing hasState utility for context-based discrimination.
431
+ *
432
+ * @template Name - The unique name for this case
433
+ * @template M - The machine type (use Machine<YourContextUnion> for proper narrowing)
434
+ * @template K - The context key to check
435
+ * @template V - The value to match
436
+ * @param name - The name to use for this case
437
+ * @param key - The context property to check
438
+ * @param value - The value the property should equal
439
+ * @returns A matcher case tuple
440
+ *
441
+ * @example
442
+ * ```typescript
443
+ * type FetchContext =
444
+ * | { status: 'idle' }
445
+ * | { status: 'loading' }
446
+ * | { status: 'success'; data: string };
447
+ *
448
+ * const match = createMatcher(
449
+ * discriminantCase<'idle', Machine<FetchContext>, 'status', 'idle'>('idle', 'status', 'idle'),
450
+ * discriminantCase<'loading', Machine<FetchContext>, 'status', 'loading'>('loading', 'status', 'loading'),
451
+ * discriminantCase<'success', Machine<FetchContext>, 'status', 'success'>('success', 'status', 'success')
452
+ * );
453
+ * ```
454
+ */
455
+ export function discriminantCase<
456
+ Name extends string,
457
+ M extends Machine<any> = Machine<any>,
458
+ K extends keyof Context<M> = any,
459
+ V extends Context<M>[K] = any
460
+ >(
461
+ name: Name,
462
+ key: K,
463
+ value: V
464
+ ): MatcherCase<
465
+ Name,
466
+ M & { context: Extract<Context<M>, { [P in K]: V }> },
467
+ (m: M) => m is M & { context: Extract<Context<M>, { [P in K]: V }> }
468
+ > {
469
+ return [
470
+ name,
471
+ undefined as any, // Type-only, not used at runtime
472
+ (m): m is any => hasState(m, key as any, value as any)
473
+ ] as const;
474
+ }
475
+
476
+ /**
477
+ * Creates a custom matcher case with a user-defined predicate.
478
+ * For advanced matching logic beyond instanceof or discriminants.
479
+ *
480
+ * @template Name - The unique name for this case
481
+ * @template M - The machine type this case matches (inferred from predicate)
482
+ * @param name - The name to use for this case
483
+ * @param predicate - A type guard function that determines if a machine matches
484
+ * @returns A matcher case tuple
485
+ *
486
+ * @example
487
+ * ```typescript
488
+ * const match = createMatcher(
489
+ * customCase('complex', (m): m is ComplexMachine => {
490
+ * return m.context.value > 10 && m.context.status === 'active';
491
+ * })
492
+ * );
493
+ * ```
494
+ */
495
+ export function customCase<
496
+ const Name extends string,
497
+ M
498
+ >(
499
+ name: Name,
500
+ predicate: (m: any) => m is M
501
+ ): MatcherCase<Name, M, (m: any) => m is M> {
502
+ return [name, undefined as any, predicate] as const;
503
+ }
504
+
505
+ /**
506
+ * Creates a discriminated matcher builder for a specific context union type.
507
+ * Provides better type inference by capturing the context type upfront.
508
+ *
509
+ * @template C - The discriminated union context type
510
+ * @returns A builder object with a `case` method for defining cases with less boilerplate
511
+ *
512
+ * @example
513
+ * ```typescript
514
+ * type FetchContext =
515
+ * | { status: 'idle' }
516
+ * | { status: 'loading'; startTime: number }
517
+ * | { status: 'success'; data: string };
518
+ *
519
+ * const builder = forContext<FetchContext>();
520
+ *
521
+ * const match = createMatcher(
522
+ * builder.case('idle', 'status', 'idle'),
523
+ * builder.case('loading', 'status', 'loading'),
524
+ * builder.case('success', 'status', 'success')
525
+ * );
526
+ *
527
+ * // Full type inference and narrowing works!
528
+ * if (match.is.success(machine)) {
529
+ * console.log(machine.context.data); // ✓ TypeScript knows data exists
530
+ * }
531
+ * ```
532
+ */
533
+ export function forContext<C extends object>() {
534
+
535
+
536
+ return {
537
+ /**
538
+ * Creates a discriminated union case with full type inference.
539
+ */
540
+ case<
541
+ Name extends string,
542
+ K extends keyof C,
543
+ V extends C[K]
544
+ >(
545
+ name: Name,
546
+ key: K,
547
+ value: V
548
+ ): MatcherCase<
549
+ Name,
550
+ { readonly context: Extract<C, { [P in K]: V }> },
551
+ (m: { readonly context: C }) => m is { readonly context: Extract<C, { [P in K]: V }> }
552
+ > {
553
+ return [
554
+ name,
555
+ undefined as any,
556
+ (m): m is any => hasState(m, key as any, value as any)
557
+ ] as const;
558
+ }
559
+ };
560
+ }