hanzio 2.1.0 → 3.0.0

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.
@@ -0,0 +1,335 @@
1
+ # typedswitch
2
+
3
+ A type-safe, exhaustive switch expression for TypeScript. Handle discriminated unions and string literals with full type inference, compile-time exhaustiveness checking, and async support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install typedswitch
9
+ ```
10
+
11
+ ```bash
12
+ pnpm add typedswitch
13
+ ```
14
+
15
+ ```bash
16
+ bun add typedswitch
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Full type inference** — Return types are automatically inferred from your handlers
22
+ - **Exhaustiveness checking** — TypeScript ensures all cases are handled at compile time
23
+ - **Discriminated union support** — Works with any discriminant key, not just `type`
24
+ - **Async-aware** — Async handlers preserve their `Promise` return type
25
+ - **Grouped cases** — Share one handler between several values, fully typed
26
+ - **Default handlers** — Handle remaining cases with a fallback
27
+ - **Typo-proof cases** — A case key that isn't a value of the union is a compile error
28
+ - **`assertNever`** — Exhaustiveness checking for regular `switch` statements
29
+
30
+ ## Usage
31
+
32
+ ### Plain Value Input
33
+
34
+ Switch on a string, number, boolean or enum value directly:
35
+
36
+ ```typescript
37
+ import { typedSwitch } from 'typedswitch'
38
+
39
+ type Status = 'success' | 'error' | 'pending'
40
+
41
+ const status: Status = 'success'
42
+
43
+ // All cases required — TypeScript enforces exhaustiveness
44
+ const message = typedSwitch(status, {
45
+ success: () => 'Operation completed!',
46
+ error: () => 'Something went wrong',
47
+ pending: () => 'Please wait...',
48
+ })
49
+ // message: string
50
+ ```
51
+
52
+ Each handler receives the narrowed value:
53
+
54
+ ```typescript
55
+ const result = typedSwitch(status, {
56
+ success: (val) => val.toUpperCase(), // val: 'success'
57
+ error: (val) => val.toUpperCase(), // val: 'error'
58
+ pending: (val) => val.toUpperCase(), // val: 'pending'
59
+ })
60
+ ```
61
+
62
+ Numbers and booleans are keyed by `String(value)`, and each handler still
63
+ receives the typed value:
64
+
65
+ ```typescript
66
+ typedSwitch(code, {
67
+ 200: (c) => 'ok', // c: 200
68
+ 404: (c) => `missing ${c}`, // c: 404
69
+ })
70
+
71
+ typedSwitch(enabled, {
72
+ true: () => 'on',
73
+ false: () => 'off',
74
+ })
75
+ ```
76
+
77
+ ### Discriminated Union Input
78
+
79
+ Switch on objects with a string, number, or boolean discriminant property (like `type`, `kind`, `status`, etc.):
80
+
81
+ ```typescript
82
+ type Event =
83
+ | { type: 'click'; x: number; y: number }
84
+ | { type: 'scroll'; offset: number }
85
+ | { type: 'keypress'; key: string }
86
+
87
+ const event: Event = { type: 'click', x: 100, y: 200 }
88
+
89
+ // Specify the discriminant key as the second argument
90
+ const description = typedSwitch(event, 'type', {
91
+ click: (e) => `Clicked at (${e.x}, ${e.y})`, // e: { type: 'click'; x: number; y: number }
92
+ scroll: (e) => `Scrolled ${e.offset}px`, // e: { type: 'scroll'; offset: number }
93
+ keypress: (e) => `Pressed ${e.key}`, // e: { type: 'keypress'; key: string }
94
+ })
95
+ // description: string
96
+ ```
97
+
98
+ Works with any discriminant key:
99
+
100
+ ```typescript
101
+ type Order =
102
+ | { status: 'pending'; createdAt: Date }
103
+ | { status: 'completed'; completedAt: Date }
104
+ | { status: 'cancelled'; reason: string }
105
+
106
+ const order: Order = { status: 'completed', completedAt: new Date() }
107
+
108
+ const info = typedSwitch(order, 'status', {
109
+ pending: (o) => `Created: ${o.createdAt}`,
110
+ completed: (o) => `Done: ${o.completedAt}`,
111
+ cancelled: (o) => `Cancelled: ${o.reason}`,
112
+ })
113
+ ```
114
+
115
+ Number and boolean discriminants work too. Cases are keyed by `String(value)`:
116
+
117
+ ```typescript
118
+ type Response =
119
+ | { code: 200; body: string }
120
+ | { code: 404; path: string }
121
+
122
+ typedSwitch(response, 'code', {
123
+ 200: (r) => r.body, // r: { code: 200; body: string }
124
+ 404: (r) => `Missing ${r.path}`, // r: { code: 404; path: string }
125
+ })
126
+
127
+ type Result = { ok: true; value: number } | { ok: false; error: string }
128
+
129
+ typedSwitch(result, 'ok', {
130
+ true: (r) => r.value,
131
+ false: (r) => r.error,
132
+ })
133
+ ```
134
+
135
+ ### Grouped Cases
136
+
137
+ Spread `typedSwitchGroup` into the cases to share one handler between several
138
+ values. Pass the switched value (and key) so the types are inferred. The
139
+ handler receives the union of the grouped members, and exhaustiveness checking,
140
+ default handlers and return type inference keep working:
141
+
142
+ ```typescript
143
+ import { typedSwitch, typedSwitchGroup } from 'typedswitch'
144
+
145
+ const description = typedSwitch(event, 'type', {
146
+ ...typedSwitchGroup(event, 'type', ['click', 'scroll'], (e) => e.type), // e: click or scroll event
147
+ keypress: (e) => `Pressed ${e.key}`,
148
+ })
149
+
150
+ const message = typedSwitch(status, {
151
+ ...typedSwitchGroup(status, ['error', 'cancelled'], (s) => `Stopped: ${s}`), // s: 'error' | 'cancelled'
152
+ success: () => 'Done!',
153
+ pending: () => 'Please wait...',
154
+ })
155
+ ```
156
+
157
+ Group keys must be values of the discriminant. A case written after the spread
158
+ replaces the grouped handler for that key; writing it before the spread is a
159
+ type error. If two groups list the same key, the later group wins.
160
+
161
+ ### Default Handler
162
+
163
+ Handle a subset of cases and catch the rest with a default handler:
164
+
165
+ ```typescript
166
+ type Status = 'success' | 'error' | 'pending' | 'cancelled'
167
+
168
+ const status: Status = 'cancelled'
169
+
170
+ // Only handle specific cases, default handles the rest
171
+ const message = typedSwitch(
172
+ status,
173
+ {
174
+ success: () => 'Done!',
175
+ error: () => 'Failed!',
176
+ },
177
+ (val) => `Unhandled status: ${val}` // val: 'pending' | 'cancelled' (unhandled cases only)
178
+ )
179
+ // message: string
180
+ ```
181
+
182
+ Only required, defined handlers narrow the default handler's input. Optional
183
+ handlers (including reusable `PartialStringSwitchCases` and
184
+ `PartialObjectSwitchCases` maps) can be absent, so their cases remain in the
185
+ default handler's input type.
186
+
187
+ Works with discriminated unions too:
188
+
189
+ ```typescript
190
+ const description = typedSwitch(
191
+ event,
192
+ 'type',
193
+ {
194
+ click: (e) => `Clicked at (${e.x}, ${e.y})`,
195
+ },
196
+ (e) => `Unhandled event: ${e.type}` // e: scroll or keypress event (unhandled members only)
197
+ )
198
+ ```
199
+
200
+ ### Async Handlers
201
+
202
+ Mix sync and async handlers freely. `typedSwitch` returns the selected handler's value directly, so async branches preserve their `Promise` return type while sync branches remain sync:
203
+
204
+ ```typescript
205
+ // All sync — returns string
206
+ const syncResult = typedSwitch(status, {
207
+ success: () => 'ok',
208
+ error: () => 'err',
209
+ pending: () => 'wait',
210
+ })
211
+ // syncResult: string
212
+
213
+ // All async — returns Promise<string>
214
+ const asyncResult = typedSwitch(status, {
215
+ success: async () => {
216
+ const data = await fetchData()
217
+ return data.message
218
+ },
219
+ error: async () => 'err',
220
+ pending: async () => 'wait',
221
+ })
222
+ // asyncResult: Promise<string>
223
+
224
+ const message = await asyncResult
225
+
226
+ // Mixed sync/async — returns string | Promise<string>
227
+ const mixedResult = typedSwitch(status, {
228
+ success: () => 'ok',
229
+ error: async () => 'err',
230
+ pending: () => 'wait',
231
+ })
232
+ ```
233
+
234
+ ### Return Type Inference
235
+
236
+ Return types are inferred from all handlers as a union:
237
+
238
+ ```typescript
239
+ const result = typedSwitch(status, {
240
+ success: () => ({ ok: true, data: 'hello' }),
241
+ error: () => ({ ok: false, code: 500 }),
242
+ pending: () => null,
243
+ })
244
+ // result: { ok: true; data: string } | { ok: false; code: number } | null
245
+ ```
246
+
247
+ ### Enforcing a Return Type
248
+
249
+ Annotate the result. Every handler's return type must fit, and exhaustiveness
250
+ checking still applies:
251
+
252
+ ```typescript
253
+ interface HasId {
254
+ id: string
255
+ }
256
+
257
+ const result: HasId = typedSwitch(status, {
258
+ success: () => ({ id: '123', name: 'John' }),
259
+ error: () => ({ id: '456', code: 500 }),
260
+ pending: () => ({ id: '789' }),
261
+ })
262
+ ```
263
+
264
+ ## Error Handling
265
+
266
+ When a case is unhandled at runtime (e.g., due to unsafe casts), `typedSwitch` throws a descriptive error:
267
+
268
+ ```typescript
269
+ const status = 'unknown' as Status
270
+
271
+ typedSwitch(status, {
272
+ success: () => 'ok',
273
+ error: () => 'err',
274
+ })
275
+ // Throws: "Unhandled case: unknown. Available cases: success, error"
276
+ ```
277
+
278
+ For discriminated unions:
279
+
280
+ ```typescript
281
+ // Throws: "Unhandled case: unknown (discriminant key: "type"). Available cases: click, scroll, keypress"
282
+ ```
283
+
284
+ Object discriminants must be strings, numbers, or booleans. Missing or other discriminant values throw a runtime error if they reach `typedSwitch`.
285
+
286
+ ## `assertNever`
287
+
288
+ For regular `switch` statements, put `assertNever` in the `default` branch. It fails to compile if a case is missing, and throws at runtime if an unexpected value gets through:
289
+
290
+ ```typescript
291
+ import { assertNever } from 'typedswitch'
292
+
293
+ function label(status: Status) {
294
+ switch (status) {
295
+ case 'success': return 'Done'
296
+ case 'error': return 'Failed'
297
+ case 'pending': return 'Waiting'
298
+ default: return assertNever(status) // compile error if a case is missing
299
+ }
300
+ }
301
+ ```
302
+
303
+ ## API Reference
304
+
305
+ ### `typedSwitch(value, cases)`
306
+
307
+ Switch on a string, number or boolean value with exhaustive case handling.
308
+
309
+ ### `typedSwitch(value, cases, defaultHandler)`
310
+
311
+ Switch on a string, number or boolean value with partial cases and a default fallback.
312
+
313
+ ### `typedSwitch(object, key, cases)`
314
+
315
+ Switch on a discriminated union using the specified discriminant key (string, number, or boolean values).
316
+
317
+ ### `typedSwitch(object, key, cases, defaultHandler)`
318
+
319
+ Switch on a discriminated union with partial cases and a default fallback.
320
+
321
+ ### `typedSwitchGroup(value, keys, handler)` / `typedSwitchGroup(object, key, keys, handler)`
322
+
323
+ Returns cases that all call `handler`, for spreading into `typedSwitch` cases.
324
+
325
+ ### `assertNever(value, message?)`
326
+
327
+ Takes a `never` value and always throws (`Unexpected value: ${value}` or `message`). Use it for exhaustive `switch` statements.
328
+
329
+ ### Exported Helper Types
330
+
331
+ `StringSwitchCases`, `PartialStringSwitchCases`, `ObjectSwitchCases`, and `PartialObjectSwitchCases` are exported for users who want to annotate reusable case maps.
332
+
333
+ ## License
334
+
335
+ MIT
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Supports two input modes:
5
5
  *
6
- * 1. STRING INPUT - switch on a string/enum value directly:
6
+ * 1. PLAIN VALUE INPUT - switch on a string, number or boolean value directly.
7
+ * Cases are keyed by `String(value)`:
7
8
  *
8
9
  * @example
9
10
  * const status: 'success' | 'error' = 'success'
@@ -19,6 +20,9 @@
19
20
  * success: (val) => 'handled success',
20
21
  * }, (val) => 'default handler')
21
22
  *
23
+ * typedSwitch(code, { 200: (c) => 'ok', 404: (c) => 'missing' }) // c: 200, 404
24
+ * typedSwitch(flag, { true: () => 'on', false: () => 'off' })
25
+ *
22
26
  *
23
27
  * 2. OBJECT INPUT - switch on a discriminated union with custom key:
24
28
  *
@@ -46,20 +50,27 @@
46
50
  * })
47
51
  *
48
52
  *
49
- * 3. WITH CONSTRAINT - use `typedSwitch<T>` to enforce return type:
53
+ * 3. GROUPED CASES - share one handler between several values with
54
+ * `typedSwitchGroup`. The handler receives the union of the grouped
55
+ * members, and the spread keeps exhaustiveness and return type inference:
50
56
  *
51
57
  * @example
52
- * interface HasId { id: string }
58
+ * typedSwitch(event, 'type', {
59
+ * ...typedSwitchGroup(event, 'type', ['click', 'scroll'], (e) => e.type), // e: ClickEvent | ScrollEvent
60
+ * key: (e) => e.key,
61
+ * })
53
62
  *
54
- * // All handlers must return something with { id: string }
55
- * typedSwitch<HasId>(status, {
56
- * success: () => ({ id: '123', name: 'John' }), // ✓ OK
57
- * error: () => ({ id: '456' }), // ✓ OK
63
+ * typedSwitch(status, {
64
+ * ...typedSwitchGroup(status, ['success', 'pending'], (s) => 'fine'), // s: 'success' | 'pending'
65
+ * error: () => 'failed',
58
66
  * })
59
67
  *
60
- * // Type error - missing 'id' property
61
- * typedSwitch<HasId>(status, {
62
- * success: () => ({ name: 'John' }), // ✗ Error!
68
+ *
69
+ * 4. ENFORCING A RETURN TYPE - annotate the result:
70
+ *
71
+ * @example
72
+ * const result: { id: string } = typedSwitch(status, {
73
+ * success: () => ({ id: '123' }),
63
74
  * error: () => ({ id: '456' }),
64
75
  * })
65
76
  */
@@ -145,6 +156,16 @@ export type PartialObjectSwitchCases<
145
156
  R
146
157
  > = T[K] extends Discriminant ? PartialObjectCases<T, K, R | Promise<R>> : never
147
158
 
159
+ /**
160
+ * Rejects case keys that are not values of the switched union, so a typo
161
+ * fails to compile instead of silently falling through to the default.
162
+ */
163
+ type NoUnknownCases<Cases, Allowed extends string> = {
164
+ [P in keyof Cases as `${P & (string | number)}` extends Allowed
165
+ ? never
166
+ : P]: never
167
+ }
168
+
148
169
  type Handler = (value: unknown) => unknown
149
170
  type CasesRecord = Record<string, Handler | undefined>
150
171
 
@@ -152,18 +173,25 @@ type CasesRecord = Record<string, Handler | undefined>
152
173
  // Function overloads
153
174
  // ═══════════════════════════════════════════════════════════════════════════
154
175
 
155
- // String input - all cases required (no default)
176
+ // Plain value input - all cases required (no default)
156
177
  export function typedSwitch<
157
- T extends string,
158
- Cases extends { [K in T]: (value: K) => unknown }
178
+ T extends Discriminant,
179
+ Cases extends { [V in T as `${V}`]: (value: V) => unknown } & NoUnknownCases<
180
+ Cases,
181
+ `${T}`
182
+ >
159
183
  >(value: T, cases: Cases): RawCasesReturnType<Cases>
160
184
 
161
- // String input - partial cases allowed (with default)
185
+ // Plain value input - partial cases allowed (with default)
162
186
  // The default handler only receives the cases not covered by `cases`.
163
187
  export function typedSwitch<
164
- T extends string,
165
- Cases extends { [K in T]?: (value: K) => unknown },
166
- DefaultCase extends (value: Exclude<T, HandledCaseKeys<Cases>>) => unknown
188
+ T extends Discriminant,
189
+ Cases extends {
190
+ [V in T as `${V}`]?: (value: V) => unknown
191
+ } & NoUnknownCases<Cases, `${T}`>,
192
+ DefaultCase extends (
193
+ value: Exclude<T, ValuesForKeys<T, HandledCaseKeys<Cases>>>
194
+ ) => unknown
167
195
  >(
168
196
  value: T,
169
197
  cases: Cases,
@@ -174,7 +202,8 @@ export function typedSwitch<
174
202
  export function typedSwitch<
175
203
  T extends object,
176
204
  K extends keyof T,
177
- Cases extends ObjectCases<T, K, unknown>
205
+ Cases extends ObjectCases<T, K, unknown> &
206
+ NoUnknownCases<Cases, `${Extract<T[K], Discriminant>}`>
178
207
  >(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>
179
208
 
180
209
  // Object input with key - partial cases allowed (with default)
@@ -182,7 +211,8 @@ export function typedSwitch<
182
211
  export function typedSwitch<
183
212
  T extends object,
184
213
  K extends keyof T,
185
- Cases extends PartialObjectCases<T, K, unknown>,
214
+ Cases extends PartialObjectCases<T, K, unknown> &
215
+ NoUnknownCases<Cases, `${Extract<T[K], Discriminant>}`>,
186
216
  DefaultCase extends (
187
217
  value: Exclude<T, Record<K, ValuesForKeys<T[K], HandledCaseKeys<Cases>>>>
188
218
  ) => unknown
@@ -193,69 +223,9 @@ export function typedSwitch<
193
223
  defaultCase: DefaultCase
194
224
  ): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>
195
225
 
196
- // String input - all cases required with return constraint (no default)
197
- export function typedSwitch<Constraint, T extends string = string>(
198
- value: T,
199
- cases: { [K in T]: (value: K) => Constraint }
200
- ): Constraint
201
- export function typedSwitch<Constraint, T extends string = string>(
202
- value: T,
203
- cases: { [K in T]: (value: K) => Constraint | Promise<Constraint> }
204
- ): Constraint | Promise<Constraint>
205
-
206
- // String input - partial cases allowed with return constraint (with default)
207
- export function typedSwitch<Constraint, T extends string = string>(
208
- value: T,
209
- cases: { [K in T]?: (value: K) => Constraint },
210
- defaultCase: (value: T) => Constraint
211
- ): Constraint
212
- export function typedSwitch<Constraint, T extends string = string>(
213
- value: T,
214
- cases: { [K in T]?: (value: K) => Constraint | Promise<Constraint> },
215
- defaultCase: (value: T) => Constraint | Promise<Constraint>
216
- ): Constraint | Promise<Constraint>
217
-
218
- // Object input with key - all cases required with return constraint (no default)
219
- export function typedSwitch<
220
- Constraint,
221
- T extends Record<string, unknown> = Record<string, unknown>,
222
- K extends keyof T = keyof T
223
- >(value: T, key: K, cases: ObjectCases<T, K, Constraint>): Constraint
224
- export function typedSwitch<
225
- Constraint,
226
- T extends Record<string, unknown> = Record<string, unknown>,
227
- K extends keyof T = keyof T
228
- >(
229
- value: T,
230
- key: K,
231
- cases: ObjectCases<T, K, Constraint | Promise<Constraint>>
232
- ): Constraint | Promise<Constraint>
233
-
234
- // Object input with key - partial cases allowed with return constraint (with default)
235
- export function typedSwitch<
236
- Constraint,
237
- T extends Record<string, unknown> = Record<string, unknown>,
238
- K extends keyof T = keyof T
239
- >(
240
- value: T,
241
- key: K,
242
- cases: PartialObjectCases<T, K, Constraint>,
243
- defaultCase: (value: T) => Constraint
244
- ): Constraint
245
- export function typedSwitch<
246
- Constraint,
247
- T extends Record<string, unknown> = Record<string, unknown>,
248
- K extends keyof T = keyof T
249
- >(
250
- value: T,
251
- key: K,
252
- cases: PartialObjectCases<T, K, Constraint | Promise<Constraint>>,
253
- defaultCase: (value: T) => Constraint | Promise<Constraint>
254
- ): Constraint | Promise<Constraint>
255
-
256
226
  // Implementation
257
227
  export function typedSwitch(
258
- value: string | object,
228
+ value: Discriminant | object,
259
229
  keyOrCases: string | CasesRecord,
260
230
  casesOrDefault?: CasesRecord | Handler,
261
231
  defaultCase?: Handler
@@ -265,9 +235,9 @@ export function typedSwitch(
265
235
  let defaultHandler: Handler | undefined
266
236
  let discriminantKey: string | undefined
267
237
 
268
- if (typeof value === 'string') {
269
- // String input: typedSwitch(string, cases, default?)
270
- key = value
238
+ if (typeof value !== 'object') {
239
+ // Plain value input: typedSwitch(value, cases, default?)
240
+ key = String(value)
271
241
  cases = keyOrCases as CasesRecord
272
242
  defaultHandler = casesOrDefault as Handler | undefined
273
243
  } else {
@@ -323,6 +293,50 @@ export function typedSwitch(
323
293
  )
324
294
  }
325
295
 
296
+ /**
297
+ * Builds cases that share one handler, for spreading into `typedSwitch` cases.
298
+ * Pass the switched value so its type is inferred; the handler receives the
299
+ * union of the grouped members. A case listed after the spread replaces the
300
+ * grouped handler for that key; listing it before the spread is a type error.
301
+ * If two groups list the same key, the later group wins.
302
+ *
303
+ * @example
304
+ * typedSwitch(event, 'type', {
305
+ * ...typedSwitchGroup(event, 'type', ['click', 'scroll'], (e) => e.type),
306
+ * key: (e) => e.key,
307
+ * })
308
+ */
309
+ export function typedSwitchGroup<
310
+ T extends Discriminant,
311
+ const Keys extends readonly T[],
312
+ R
313
+ >(
314
+ value: T,
315
+ keys: Keys,
316
+ handler: (value: Keys[number]) => R
317
+ ): { [V in Keys[number] as `${V}`]: (value: V) => R }
318
+ export function typedSwitchGroup<
319
+ T extends object,
320
+ K extends keyof T,
321
+ const Keys extends readonly Extract<T[K], Discriminant>[],
322
+ R
323
+ >(
324
+ value: T,
325
+ key: K,
326
+ keys: Keys,
327
+ handler: (value: Extract<T, Record<K, Keys[number]>>) => R
328
+ ): { [V in Keys[number] as `${V}`]: (value: Extract<T, Record<K, V>>) => R }
329
+ export function typedSwitchGroup(
330
+ _value: unknown,
331
+ keyOrKeys: unknown,
332
+ keysOrHandler: unknown,
333
+ handler?: Handler
334
+ ): CasesRecord {
335
+ const keys = (handler ? keysOrHandler : keyOrKeys) as Discriminant[]
336
+ const shared = handler ?? (keysOrHandler as Handler)
337
+ return Object.fromEntries(keys.map((key) => [String(key), shared]))
338
+ }
339
+
326
340
  /**
327
341
  * Exhaustiveness helper for regular `switch` statements. Place it in the
328
342
  * `default` branch: it only type-checks when every case has been handled, and