hanzio 2.2.0 → 3.1.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.
Files changed (116) hide show
  1. package/README.md +133 -7
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-6ragggbs.js +473 -0
  23. package/dist/index-mq2qgc20.js +116 -0
  24. package/dist/index-najbyzpr.js +99 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +22 -6
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/typedswitch/index.d.ts +55 -34
  65. package/dist/zod/index.d.ts +10 -0
  66. package/dist/zod/introspect.d.ts +2 -1
  67. package/package.json +97 -2
  68. package/src/api-wrapper/README.md +57 -2
  69. package/src/api-wrapper/SKILL.md +256 -0
  70. package/src/api-wrapper/connection-auth.ts +142 -0
  71. package/src/cli/README.md +220 -0
  72. package/src/cli/fields.ts +213 -0
  73. package/src/cli/index.ts +532 -0
  74. package/src/format/README.md +82 -2
  75. package/src/format/index.ts +148 -0
  76. package/src/format/phone.ts +43 -0
  77. package/src/http/README.md +25 -0
  78. package/src/http/index.ts +94 -0
  79. package/src/i18n/README.md +206 -3
  80. package/src/i18n/catalog.ts +139 -0
  81. package/src/i18n/check.ts +376 -0
  82. package/src/i18n/cli.ts +90 -0
  83. package/src/i18n/content-rules.ts +179 -0
  84. package/src/i18n/i18next.ts +89 -0
  85. package/src/i18n/index.ts +34 -0
  86. package/src/i18n/locales.ts +135 -0
  87. package/src/i18n/vite.ts +40 -0
  88. package/src/i18n/vue.ts +82 -0
  89. package/src/index.ts +1 -1
  90. package/src/iso/README.md +44 -0
  91. package/src/iso/countries.ts +272 -0
  92. package/src/iso/currencies.ts +172 -0
  93. package/src/iso/index.ts +8 -0
  94. package/src/iso/languages.ts +208 -0
  95. package/src/iso/phone-language.ts +46 -0
  96. package/src/promise/index.ts +100 -0
  97. package/src/redis/README.md +112 -0
  98. package/src/redis/client.ts +239 -0
  99. package/src/redis/concurrency-limit.ts +591 -0
  100. package/src/redis/index.ts +15 -0
  101. package/src/redis/lua-script.ts +43 -0
  102. package/src/secrets/README.md +5 -1
  103. package/src/secrets/infisical-identity-file.ts +69 -0
  104. package/src/secrets/infisical.ts +18 -3
  105. package/src/string/index.ts +59 -0
  106. package/src/testing/README.md +119 -0
  107. package/src/testing/connection-auth-contract.ts +303 -0
  108. package/src/testing/helpers.ts +41 -0
  109. package/src/testing/index.ts +12 -0
  110. package/src/testing/module-mocker.ts +50 -0
  111. package/src/testing/prisma.ts +78 -0
  112. package/src/testing/window.ts +119 -0
  113. package/src/typedswitch/README.md +55 -39
  114. package/src/typedswitch/index.ts +97 -83
  115. package/src/zod/index.ts +20 -0
  116. package/src/zod/introspect.ts +1 -1
@@ -0,0 +1,50 @@
1
+ type ModuleExports = Record<string, unknown>
2
+
3
+ /**
4
+ * `mock.module` that can be undone. Bun replaces a mocked module for the whole
5
+ * process and `mock.restore()` does not bring it back
6
+ * (https://github.com/oven-sh/bun/issues/7823), so one suite's stub silently
7
+ * leaks into every later suite.
8
+ *
9
+ * Mocks spread the module's real exports first, so exports you don't override
10
+ * keep working, and `clear()` re-registers the originals. Call it in
11
+ * `afterEach` or `afterAll`.
12
+ *
13
+ * Bun resolves specifiers relative to the file that registers the mock, so
14
+ * pass absolute paths or package names. A `resolve` function maps your own
15
+ * shorthand (an `@/` alias, a source-root-relative path) to absolute paths.
16
+ *
17
+ * @example
18
+ * const moduleMocker = new ModuleMocker((specifier) =>
19
+ * specifier.startsWith('@/') ? path.resolve(srcDir, specifier.slice(2)) : specifier
20
+ * )
21
+ * await moduleMocker.mock('@/lib/posthog', () => ({ captureException: mock() }))
22
+ * afterAll(() => moduleMocker.clear())
23
+ */
24
+ export class ModuleMocker {
25
+ private restorers: (() => void)[] = []
26
+
27
+ constructor(
28
+ private readonly resolve: (specifier: string) => string = (specifier) =>
29
+ specifier
30
+ ) {}
31
+
32
+ async mock(
33
+ specifier: string,
34
+ renderMocks: () => ModuleExports
35
+ ): Promise<void> {
36
+ const { mock } = await import('bun:test')
37
+ const resolved = this.resolve(specifier)
38
+ const original = { ...(await import(resolved)) } as ModuleExports
39
+ const merged = { ...original, ...renderMocks() }
40
+ mock.module(resolved, () => merged)
41
+ this.restorers.push(() => {
42
+ mock.module(resolved, () => original)
43
+ })
44
+ }
45
+
46
+ clear(): void {
47
+ for (const restore of this.restorers) restore()
48
+ this.restorers = []
49
+ }
50
+ }
@@ -0,0 +1,78 @@
1
+ import type { Mock, mock } from 'bun:test'
2
+
3
+ export type PrismaMethodMock = Mock<(...args: unknown[]) => Promise<unknown>>
4
+
5
+ /**
6
+ * A Prisma client where every model delegate and every method is a Bun mock,
7
+ * created on first access, like `jest-mock-extended`'s `mockDeep()`.
8
+ * `$`-prefixed client methods (`$transaction`, `$queryRaw`) are mocks too.
9
+ */
10
+ export type DeepMockedPrisma<Client> = {
11
+ [K in keyof Client]: Client[K] extends (...args: never[]) => unknown
12
+ ? PrismaMethodMock
13
+ : { [Method in keyof Client[K]]: PrismaMethodMock }
14
+ } & {
15
+ /** Resets every mock created so far. Call it in `beforeEach`. */
16
+ $reset: () => void
17
+ }
18
+
19
+ /**
20
+ * Builds a deep auto-mock of a Prisma client from Bun's `mock`. Register it
21
+ * with `mock.module` in place of the module that exports your client.
22
+ *
23
+ * @example
24
+ * import { mock } from 'bun:test'
25
+ * import type { prisma } from '../lib/prisma'
26
+ *
27
+ * export const prismaMock = createPrismaMock<typeof prisma>(mock)
28
+ * mock.module(prismaModulePath, () => ({ ...prismaExports, prisma: prismaMock }))
29
+ *
30
+ * prismaMock.user.findUnique.mockResolvedValue({ id: 'u1' })
31
+ * expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ where: { id: 'u1' } })
32
+ */
33
+ export function createPrismaMock<Client>(
34
+ createMock: typeof mock
35
+ ): DeepMockedPrisma<Client> {
36
+ const created: PrismaMethodMock[] = []
37
+ const reset = () => {
38
+ for (const method of created) method.mockReset()
39
+ }
40
+
41
+ const methodsOf = (methods: Map<string, PrismaMethodMock>, key: string) => {
42
+ let method = methods.get(key)
43
+ if (!method) {
44
+ method = createMock() as unknown as PrismaMethodMock
45
+ created.push(method)
46
+ methods.set(key, method)
47
+ }
48
+ return method
49
+ }
50
+
51
+ const delegates = new Map<string, object>()
52
+ const rootMethods = new Map<string, PrismaMethodMock>()
53
+
54
+ const delegateFor = (model: string) => {
55
+ let delegate = delegates.get(model)
56
+ if (!delegate) {
57
+ const methods = new Map<string, PrismaMethodMock>()
58
+ delegate = new Proxy(
59
+ {},
60
+ {
61
+ get: (_target, method) =>
62
+ typeof method === 'symbol' ? undefined : methodsOf(methods, method)
63
+ }
64
+ )
65
+ delegates.set(model, delegate)
66
+ }
67
+ return delegate
68
+ }
69
+
70
+ return new Proxy({} as DeepMockedPrisma<Client>, {
71
+ get(_target, property) {
72
+ if (property === '$reset') return reset
73
+ if (typeof property === 'symbol') return undefined
74
+ if (property.startsWith('$')) return methodsOf(rootMethods, property)
75
+ return delegateFor(property)
76
+ }
77
+ })
78
+ }
@@ -0,0 +1,119 @@
1
+ /** A complete in-memory `Storage` (`localStorage` / `sessionStorage`). */
2
+ export function createTestStorage(): Storage {
3
+ const store = new Map<string, string>()
4
+ return {
5
+ get length() {
6
+ return store.size
7
+ },
8
+ clear: () => store.clear(),
9
+ getItem: (key) => store.get(key) ?? null,
10
+ key: (index) => [...store.keys()][index] ?? null,
11
+ removeItem: (key) => {
12
+ store.delete(key)
13
+ },
14
+ setItem: (key, value) => {
15
+ store.set(key, String(value))
16
+ }
17
+ }
18
+ }
19
+
20
+ type ShimLocation = Pick<
21
+ URL,
22
+ | 'href'
23
+ | 'origin'
24
+ | 'protocol'
25
+ | 'host'
26
+ | 'hostname'
27
+ | 'port'
28
+ | 'pathname'
29
+ | 'search'
30
+ | 'hash'
31
+ >
32
+
33
+ type ShimWindow = {
34
+ location: ShimLocation
35
+ addEventListener: (...args: unknown[]) => void
36
+ removeEventListener: (...args: unknown[]) => void
37
+ localStorage: Storage
38
+ }
39
+
40
+ const noop = () => {}
41
+
42
+ function locationOf(href: string): ShimLocation {
43
+ const { origin, protocol, host, hostname, port, pathname, search, hash } =
44
+ new URL(href)
45
+ return {
46
+ href,
47
+ origin,
48
+ protocol,
49
+ host,
50
+ hostname,
51
+ port,
52
+ pathname,
53
+ search,
54
+ hash
55
+ }
56
+ }
57
+
58
+ function isObject(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === 'object' && value !== null
60
+ }
61
+
62
+ /**
63
+ * Installs a minimal `globalThis.window` for unit tests of browser-facing code
64
+ * that only probes `window` (`location`, `localStorage`, event listeners), and
65
+ * returns a `reset` to call in `afterEach`.
66
+ *
67
+ * Tests may assign a partial `window`. The shim keeps its own shape and copies
68
+ * `location`, `localStorage` and the listener functions from the assigned
69
+ * object, because libraries such as axios and PostHog read those at import
70
+ * time. It installs no `document`: Vue and PrismJS treat one as a full DOM.
71
+ *
72
+ * @example
73
+ * // test-setup.ts, preloaded through bunfig.toml
74
+ * const resetWindow = installWindowShim()
75
+ * afterEach(resetWindow)
76
+ */
77
+ export function installWindowShim(href = 'http://localhost/'): () => void {
78
+ const shim: ShimWindow = {
79
+ location: locationOf(href),
80
+ addEventListener: noop,
81
+ removeEventListener: noop,
82
+ localStorage: createTestStorage()
83
+ }
84
+
85
+ const assign = (value: unknown) => {
86
+ if (!isObject(value)) return
87
+ if (value.localStorage) shim.localStorage = value.localStorage as Storage
88
+ shim.location =
89
+ isObject(value.location) && typeof value.location.href === 'string'
90
+ ? (value.location as ShimLocation)
91
+ : locationOf(href)
92
+ shim.addEventListener =
93
+ typeof value.addEventListener === 'function'
94
+ ? (value.addEventListener as ShimWindow['addEventListener'])
95
+ : noop
96
+ shim.removeEventListener =
97
+ typeof value.removeEventListener === 'function'
98
+ ? (value.removeEventListener as ShimWindow['removeEventListener'])
99
+ : noop
100
+ }
101
+
102
+ const install = () => {
103
+ Object.defineProperty(globalThis, 'window', {
104
+ configurable: true,
105
+ enumerable: true,
106
+ get: () => shim,
107
+ set: assign
108
+ })
109
+ }
110
+
111
+ install()
112
+ return () => {
113
+ shim.location = locationOf(href)
114
+ shim.addEventListener = noop
115
+ shim.removeEventListener = noop
116
+ shim.localStorage = createTestStorage()
117
+ install()
118
+ }
119
+ }
@@ -22,15 +22,16 @@ bun add typedswitch
22
22
  - **Exhaustiveness checking** — TypeScript ensures all cases are handled at compile time
23
23
  - **Discriminated union support** — Works with any discriminant key, not just `type`
24
24
  - **Async-aware** — Async handlers preserve their `Promise` return type
25
+ - **Grouped cases** — Share one handler between several values, fully typed
25
26
  - **Default handlers** — Handle remaining cases with a fallback
26
- - **Return type constraints** — Enforce that all handlers return a specific type
27
+ - **Typo-proof cases** — A case key that isn't a value of the union is a compile error
27
28
  - **`assertNever`** — Exhaustiveness checking for regular `switch` statements
28
29
 
29
30
  ## Usage
30
31
 
31
- ### String/Enum Input
32
+ ### Plain Value Input
32
33
 
33
- Switch on a string literal or enum value directly:
34
+ Switch on a string, number, boolean or enum value directly:
34
35
 
35
36
  ```typescript
36
37
  import { typedSwitch } from 'typedswitch'
@@ -58,6 +59,21 @@ const result = typedSwitch(status, {
58
59
  })
59
60
  ```
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
+
61
77
  ### Discriminated Union Input
62
78
 
63
79
  Switch on objects with a string, number, or boolean discriminant property (like `type`, `kind`, `status`, etc.):
@@ -116,6 +132,32 @@ typedSwitch(result, 'ok', {
116
132
  })
117
133
  ```
118
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
+
119
161
  ### Default Handler
120
162
 
121
163
  Handle a subset of cases and catch the rest with a default handler:
@@ -202,45 +244,23 @@ const result = typedSwitch(status, {
202
244
  // result: { ok: true; data: string } | { ok: false; code: number } | null
203
245
  ```
204
246
 
205
- ### Return Type Constraints
247
+ ### Enforcing a Return Type
206
248
 
207
- Enforce that all handlers return a type extending a constraint with `typedSwitch<Constraint>(...)`. The `<Constraint>` is optional; omit it when you want TypeScript to infer the return types freely.
249
+ Annotate the result. Every handler's return type must fit, and exhaustiveness
250
+ checking still applies:
208
251
 
209
252
  ```typescript
210
253
  interface HasId {
211
254
  id: string
212
255
  }
213
256
 
214
- // All handlers must return something with { id: string }
215
- const result = typedSwitch<HasId>(status, {
216
- success: () => ({ id: '123', name: 'John' }), // ✓ OK
217
- error: () => ({ id: '456', code: 500 }), // ✓ OK
218
- pending: () => ({ id: '789' }), // ✓ OK
219
- })
220
- // result: HasId
221
-
222
- // This would be a compile error:
223
- typedSwitch<HasId>(status, {
224
- success: () => ({ name: 'John' }), // ✗ Error: missing 'id'
225
- error: () => ({ id: '456' }),
257
+ const result: HasId = typedSwitch(status, {
258
+ success: () => ({ id: '123', name: 'John' }),
259
+ error: () => ({ id: '456', code: 500 }),
226
260
  pending: () => ({ id: '789' }),
227
261
  })
228
262
  ```
229
263
 
230
- Constraints also work with discriminated unions and default handlers:
231
-
232
- ```typescript
233
- const result = typedSwitch<HasId>(
234
- event,
235
- 'type',
236
- {
237
- click: () => ({ id: 'click-1', x: 10, y: 20 }),
238
- scroll: () => ({ id: 'scroll-1', offset: 100 }),
239
- keypress: () => ({ id: 'key-1', key: 'Enter' }),
240
- }
241
- )
242
- ```
243
-
244
264
  ## Error Handling
245
265
 
246
266
  When a case is unhandled at runtime (e.g., due to unsafe casts), `typedSwitch` throws a descriptive error:
@@ -284,11 +304,11 @@ function label(status: Status) {
284
304
 
285
305
  ### `typedSwitch(value, cases)`
286
306
 
287
- Switch on a string literal with exhaustive case handling.
307
+ Switch on a string, number or boolean value with exhaustive case handling.
288
308
 
289
309
  ### `typedSwitch(value, cases, defaultHandler)`
290
310
 
291
- Switch on a string literal with partial cases and a default fallback.
311
+ Switch on a string, number or boolean value with partial cases and a default fallback.
292
312
 
293
313
  ### `typedSwitch(object, key, cases)`
294
314
 
@@ -298,13 +318,9 @@ Switch on a discriminated union using the specified discriminant key (string, nu
298
318
 
299
319
  Switch on a discriminated union with partial cases and a default fallback.
300
320
 
301
- ### `typedSwitch<Constraint>(value, cases)`
302
-
303
- Switch on a string literal while enforcing that each handler returns `Constraint` or `Promise<Constraint>`.
304
-
305
- ### `typedSwitch<Constraint>(object, key, cases)`
321
+ ### `typedSwitchGroup(value, keys, handler)` / `typedSwitchGroup(object, key, keys, handler)`
306
322
 
307
- Switch on a discriminated union while enforcing that each handler returns `Constraint` or `Promise<Constraint>`.
323
+ Returns cases that all call `handler`, for spreading into `typedSwitch` cases.
308
324
 
309
325
  ### `assertNever(value, message?)`
310
326
 
@@ -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
package/src/zod/index.ts CHANGED
@@ -97,6 +97,26 @@ export const zJsonObject: z.ZodType<JsonObject> = z.record(
97
97
  zJsonValue
98
98
  )
99
99
 
100
+ /** An E.164 phone number: `+`, then 7 to 15 digits with no leading zero. */
101
+ export const E164_REGEX = /^\+[1-9]\d{6,14}$/
102
+
103
+ /**
104
+ * An optional E.164 phone number for forms: a cleared input submits `''`,
105
+ * which means "no number", so `''` and `null` both parse to `null`.
106
+ *
107
+ * Deliberately not `union([regex, literal('')])`: that serializes the empty
108
+ * literal as `enum: [""]`, which Gemini function declarations reject.
109
+ */
110
+ export const zOptionalE164 = z
111
+ .string()
112
+ .refine(
113
+ (value) => value === '' || E164_REGEX.test(value),
114
+ 'Phone number must be in E.164 format (e.g. +14155551234)'
115
+ )
116
+ .describe('E.164 phone number (e.g. +14155551234); empty string clears it')
117
+ .nullable()
118
+ .transform((value) => value || null)
119
+
100
120
  /** `JSON.parse` + `schema.safeParse` without throwing. */
101
121
  export function parseJson<S extends z.ZodType>(
102
122
  text: string,