hanzio 1.1.0 → 2.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.
Files changed (172) hide show
  1. package/README.md +832 -219
  2. package/dist/api-wrapper/builders.d.ts +15 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +40 -2
  7. package/dist/api-wrapper/headers.d.ts +2 -1
  8. package/dist/api-wrapper/index.d.ts +5 -0
  9. package/dist/api-wrapper/otel.d.ts +44 -0
  10. package/dist/api-wrapper/paginate.d.ts +12 -0
  11. package/dist/api-wrapper/redaction.d.ts +2 -0
  12. package/dist/api-wrapper/shared.d.ts +16 -2
  13. package/dist/api-wrapper/testing.d.ts +23 -0
  14. package/dist/api-wrapper/transport.d.ts +12 -10
  15. package/dist/api-wrapper/types.d.ts +104 -18
  16. package/dist/array/index.d.ts +50 -4
  17. package/dist/cache/createCache.d.ts +31 -0
  18. package/dist/cache/index.d.ts +45 -2
  19. package/dist/color/contrast.d.ts +40 -0
  20. package/dist/color/index.d.ts +2 -0
  21. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  22. package/dist/cool-console-log/index.d.ts +34 -5
  23. package/dist/crypto/encoding.d.ts +18 -0
  24. package/dist/crypto/hash.d.ts +33 -0
  25. package/dist/crypto/index.d.ts +4 -0
  26. package/dist/crypto/sealer.d.ts +25 -0
  27. package/dist/crypto/webhook.d.ts +20 -0
  28. package/dist/date/index.d.ts +46 -2
  29. package/dist/emitter/index.d.ts +21 -0
  30. package/dist/error/index.d.ts +12 -0
  31. package/dist/format/index.d.ts +102 -0
  32. package/dist/function/index.d.ts +60 -0
  33. package/dist/guard/index.d.ts +20 -0
  34. package/dist/i18n/index.d.ts +37 -0
  35. package/dist/index-0zvtcbzy.js +224 -0
  36. package/dist/index-181e6vfw.js +29 -0
  37. package/dist/index-8r2vdzjg.js +87 -0
  38. package/dist/index-hy7kbb99.js +157 -0
  39. package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
  40. package/dist/index-k9yx6bne.js +89 -0
  41. package/dist/index-kj1phgrx.js +169 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-srt06xd2.js +15 -0
  46. package/dist/index-wjyb9bsd.js +263 -0
  47. package/dist/index.d.ts +7 -7
  48. package/dist/jwt/index.d.ts +50 -2
  49. package/dist/math/index.d.ts +2 -2
  50. package/dist/math/linearRegressionTrend.d.ts +14 -1
  51. package/dist/math/scale.d.ts +8 -0
  52. package/dist/object/index.d.ts +19 -0
  53. package/dist/p-queue/index.d.ts +1 -1
  54. package/dist/promise/index.d.ts +97 -2
  55. package/dist/secrets/core.d.ts +47 -0
  56. package/dist/secrets/deadline.d.ts +6 -0
  57. package/dist/secrets/environment.d.ts +9 -0
  58. package/dist/secrets/errors.d.ts +11 -0
  59. package/dist/secrets/index.d.ts +13 -25
  60. package/dist/secrets/infisical-cli.d.ts +6 -0
  61. package/dist/secrets/infisical-config.d.ts +17 -0
  62. package/dist/secrets/infisical-session.d.ts +4 -0
  63. package/dist/secrets/infisical.d.ts +8 -13
  64. package/dist/secrets/loaders.d.ts +5 -12
  65. package/dist/secrets/vite.d.ts +7 -3
  66. package/dist/secrets/worker-loader.d.ts +9 -0
  67. package/dist/secrets/worker.d.ts +3 -0
  68. package/dist/sitemap/index.d.ts +48 -6
  69. package/dist/src/api-wrapper/index.js +60 -1100
  70. package/dist/src/api-wrapper/testing.js +20 -0
  71. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  72. package/dist/src/crypto/index.js +195 -0
  73. package/dist/src/format/index.js +211 -0
  74. package/dist/src/i18n/index.js +36 -0
  75. package/dist/src/index.js +863 -2809
  76. package/dist/src/jwt/index.js +138 -92
  77. package/dist/src/p-queue/index.js +9 -437
  78. package/dist/src/secrets/index.js +261 -1377
  79. package/dist/src/secrets/vite.js +6 -17
  80. package/dist/src/secrets/worker.js +12 -0
  81. package/dist/src/sitemap/index.js +183 -511
  82. package/dist/src/storage/index.js +119 -0
  83. package/dist/src/zod/index.js +347 -174
  84. package/dist/state/index.d.ts +21 -0
  85. package/dist/storage/index.d.ts +58 -0
  86. package/dist/string/index.d.ts +75 -2
  87. package/dist/typedswitch/index.d.ts +37 -26
  88. package/dist/types/index.d.ts +11 -0
  89. package/dist/url/index.d.ts +50 -1
  90. package/dist/zod/index.d.ts +32 -3
  91. package/dist/zod/introspect.d.ts +19 -0
  92. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  93. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  94. package/dist/zod/zodToTypeString.d.ts +8 -3
  95. package/package.json +87 -7
  96. package/src/api-wrapper/actions-impl.ts +11 -72
  97. package/src/api-wrapper/builders.ts +84 -52
  98. package/src/api-wrapper/create-client.ts +246 -96
  99. package/src/api-wrapper/describe.ts +157 -0
  100. package/src/api-wrapper/endpoint.ts +26 -0
  101. package/src/api-wrapper/error-reporting.ts +7 -1
  102. package/src/api-wrapper/errors.ts +117 -2
  103. package/src/api-wrapper/headers.ts +19 -1
  104. package/src/api-wrapper/index.ts +5 -0
  105. package/src/api-wrapper/otel.ts +139 -0
  106. package/src/api-wrapper/paginate.ts +28 -0
  107. package/src/api-wrapper/redaction.ts +43 -0
  108. package/src/api-wrapper/shared.ts +21 -2
  109. package/src/api-wrapper/testing.ts +48 -0
  110. package/src/api-wrapper/transport.ts +68 -74
  111. package/src/api-wrapper/types.ts +186 -18
  112. package/src/array/index.ts +195 -42
  113. package/src/cache/createCache.ts +157 -0
  114. package/src/cache/index.ts +235 -83
  115. package/src/color/contrast.ts +163 -0
  116. package/src/color/index.ts +22 -0
  117. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  118. package/src/cool-console-log/index.ts +128 -41
  119. package/src/crypto/encoding.ts +83 -0
  120. package/src/crypto/hash.ts +170 -0
  121. package/src/crypto/index.ts +26 -0
  122. package/src/crypto/sealer.ts +168 -0
  123. package/src/crypto/webhook.ts +86 -0
  124. package/src/date/index.ts +109 -34
  125. package/src/emitter/index.ts +82 -0
  126. package/src/error/index.ts +63 -0
  127. package/src/format/index.ts +389 -0
  128. package/src/function/index.ts +201 -0
  129. package/src/guard/index.ts +80 -0
  130. package/src/i18n/index.ts +125 -0
  131. package/src/index.ts +11 -7
  132. package/src/jwt/index.ts +199 -125
  133. package/src/math/index.ts +5 -10
  134. package/src/math/linearRegressionTrend.ts +33 -8
  135. package/src/math/scale.ts +17 -0
  136. package/src/object/index.ts +99 -0
  137. package/src/p-queue/index.ts +2 -0
  138. package/src/promise/index.ts +304 -9
  139. package/src/secrets/core.ts +348 -0
  140. package/src/secrets/deadline.ts +69 -0
  141. package/src/secrets/environment.ts +69 -0
  142. package/src/secrets/errors.ts +29 -0
  143. package/src/secrets/index.ts +79 -210
  144. package/src/secrets/infisical-cli.ts +99 -0
  145. package/src/secrets/infisical-config.ts +70 -0
  146. package/src/secrets/infisical-session.ts +42 -0
  147. package/src/secrets/infisical.ts +214 -66
  148. package/src/secrets/loaders.ts +24 -48
  149. package/src/secrets/vite.ts +44 -15
  150. package/src/secrets/worker-loader.ts +56 -0
  151. package/src/secrets/worker.ts +4 -0
  152. package/src/sitemap/index.ts +293 -130
  153. package/src/state/index.ts +133 -81
  154. package/src/storage/index.ts +187 -0
  155. package/src/string/index.ts +261 -23
  156. package/src/typedswitch/index.ts +134 -103
  157. package/src/types/index.ts +14 -0
  158. package/src/url/index.ts +146 -1
  159. package/src/zod/index.ts +105 -9
  160. package/src/zod/introspect.ts +103 -0
  161. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  162. package/src/zod/zodMinMaxFilter.ts +37 -13
  163. package/src/zod/zodToTypeString.ts +184 -181
  164. package/dist/api-wrapper/index.js +0 -250
  165. package/dist/gzip/index.d.ts +0 -2
  166. package/dist/gzip/index.js +0 -16
  167. package/dist/index.js +0 -1823
  168. package/dist/jwt/index.js +0 -19
  169. package/dist/sitemap/index.js +0 -552
  170. package/dist/src/gzip/index.js +0 -16
  171. package/dist/src/secrets/infisical.js +0 -1194
  172. package/dist/zod/index.js +0 -210
@@ -1,3 +1,5 @@
1
+ import type { Result } from '../promise'
2
+
1
3
  export type JsonValue =
2
4
  | string
3
5
  | number
@@ -8,18 +10,44 @@ export type JsonValue =
8
10
  export type JsonObject = { [key: string]: JsonValue | undefined }
9
11
  export type JsonArray = Array<JsonValue | undefined>
10
12
 
13
+ /**
14
+ * Parses the first number-like token in a string. Only a `-` directly before
15
+ * the digits is a sign, so `'2024-01-05'` gives `2024`. Whitespace and the
16
+ * separator that isn't `decimalSeparator` are treated as thousands separators:
17
+ * `'1,234.56'` gives `1234.56`, and `'1.234,56'` with `decimalSeparator: ','`
18
+ * gives `1234.56`.
19
+ */
11
20
  export function extractNumber(
12
- value: string | null | undefined
21
+ value: string | null | undefined,
22
+ options: { decimalSeparator?: '.' | ',' } = {}
13
23
  ): number | undefined {
14
- if (!value?.trim()) return undefined
15
-
16
- const cleanedValue = value.replace(/[^\d.,-]/g, '')
17
- if (!cleanedValue) return undefined
24
+ const { decimalSeparator = '.' } = options
25
+ const decimal = decimalSeparator === '.' ? '\\.' : ','
26
+ const thousands = decimalSeparator === '.' ? ',' : '\\.'
27
+ // First number-like token. A thousands separator (or whitespace) only counts
28
+ // when exactly three digits follow it, so '12,5' reads as 12, not 125.
29
+ const token = value?.match(
30
+ new RegExp(
31
+ `-?(?:\\d+(?:(?:${thousands}|\\s)\\d{3}(?!\\d))*(?:${decimal}\\d+)?|${decimal}\\d+)`
32
+ )
33
+ )?.[0]
34
+ if (!token) return undefined
18
35
 
19
- const parsedNumber = Number.parseFloat(cleanedValue)
36
+ const parsedNumber = Number.parseFloat(
37
+ token
38
+ .replace(/\s/g, '')
39
+ .replaceAll(decimalSeparator === '.' ? ',' : '.', '')
40
+ .replace(decimalSeparator, '.')
41
+ )
20
42
  return Number.isNaN(parsedNumber) ? undefined : parsedNumber
21
43
  }
22
44
 
45
+ /**
46
+ * A stable, short, non-cryptographic hash key (32-bit, 8 hex chars) for
47
+ * JSON-like input. Object key order and query-string parameter order don't
48
+ * matter. Collisions become likely above ~10k distinct inputs; use `sha256Id`
49
+ * where that matters.
50
+ */
23
51
  export function generateId(input: JsonValue | undefined): string {
24
52
  const data = normalizeIdInput(input)
25
53
  let hash = 0
@@ -33,32 +61,234 @@ export function generateId(input: JsonValue | undefined): string {
33
61
  return Math.abs(hash).toString(16).padStart(8, '0')
34
62
  }
35
63
 
36
- export function slugify(value: string): string {
37
- const source =
38
- 'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;'
39
- const replacement =
40
- 'aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnooooooooprrsssssttuuuuuuuuuwxyyzzz------'
41
- const specialCharacterPattern = new RegExp(source.split('').join('|'), 'g')
64
+ /** Like `generateId`, but a SHA-256 hex digest truncated to `length` chars. */
65
+ export async function sha256Id(
66
+ input: JsonValue | undefined,
67
+ length = 16
68
+ ): Promise<string> {
69
+ const digest = await crypto.subtle.digest(
70
+ 'SHA-256',
71
+ new TextEncoder().encode(normalizeIdInput(input))
72
+ )
73
+ return Array.from(new Uint8Array(digest), (byte) =>
74
+ byte.toString(16).padStart(2, '0')
75
+ )
76
+ .join('')
77
+ .slice(0, length)
78
+ }
79
+
80
+ // Letters that NFKD doesn't split into a base letter plus a combining mark.
81
+ const UNDECOMPOSED_LETTERS: Record<string, string> = {
82
+ æ: 'ae',
83
+ Æ: 'AE',
84
+ ø: 'o',
85
+ Ø: 'O',
86
+ œ: 'oe',
87
+ Œ: 'OE',
88
+ ß: 'ss',
89
+ ẞ: 'SS',
90
+ đ: 'd',
91
+ Đ: 'D',
92
+ ł: 'l',
93
+ Ł: 'L',
94
+ þ: 'th',
95
+ Þ: 'TH',
96
+ ð: 'd',
97
+ Ð: 'D'
98
+ }
42
99
 
100
+ /** Removes accents and spells out letters like `æ`, `ø` and `ß` in ASCII. */
101
+ function stripAccents(value: string): string {
43
102
  return value
44
- .toString()
45
- .toLowerCase()
46
- .replace(/\s+/g, '-')
47
- .replace(specialCharacterPattern, (character) =>
48
- replacement.charAt(source.indexOf(character))
103
+ .normalize('NFKD')
104
+ .replace(/\p{M}/gu, '')
105
+ .replace(
106
+ /[æøœßđłþðÆØŒẞĐŁÞÐ]/g,
107
+ (letter) => UNDECOMPOSED_LETTERS[letter] ?? ''
49
108
  )
50
- .replace(/&/g, '-and-')
51
- .replace(/[^\w-]+/g, '')
52
- .replace(/--+/g, '-')
53
- .replace(/^-+/, '')
54
- .replace(/-+$/, '')
109
+ }
110
+
111
+ export type SlugifyOptions = {
112
+ /** Placed between words (default `'-'`). */
113
+ separator?: string
114
+ /** Default `true`. */
115
+ lowercase?: boolean
116
+ /** Cuts the slug to at most this many characters, without a trailing separator. */
117
+ maxLength?: number
118
+ }
119
+
120
+ /**
121
+ * Turns text into a URL slug: strips accents, turns `&` into `and`, splits
122
+ * words on whitespace and `-_/,:;·`, and drops every other non-ASCII-alphanumeric
123
+ * character.
124
+ */
125
+ export function slugify(value: string, options: SlugifyOptions = {}): string {
126
+ const { separator = '-', lowercase = true, maxLength } = options
127
+ const slug = stripAccents(value)
128
+ .replace(/&/g, ' and ')
129
+ .replace(/[\s\-_/,:;·]+/g, ' ')
130
+ .replace(/[^a-zA-Z0-9 ]/g, '')
131
+ .trim()
132
+ .split(/ +/)
133
+ .join(separator)
134
+ const cased = lowercase ? slug.toLowerCase() : slug
135
+
136
+ if (maxLength === undefined || cased.length <= maxLength) return cased
137
+ // Words are alphanumeric, so anything non-alphanumeric at the end is
138
+ // (part of) a separator.
139
+ return cased.slice(0, maxLength).replace(/[^a-zA-Z0-9]+$/, '')
140
+ }
141
+
142
+ /**
143
+ * JSON.stringify with sorted object keys, so `{ a, b }` and `{ b, a }` produce
144
+ * the same string. Also encodes values JSON drops or rejects: `bigint`,
145
+ * `Map` and `Set`. Use it for cache and dedupe keys.
146
+ */
147
+ export function stableStringify(value: unknown): string {
148
+ return (
149
+ JSON.stringify(value, (_key, item: unknown) => {
150
+ if (typeof item === 'bigint') return `${item}n`
151
+ if (item instanceof Map) return { $map: [...item.entries()] }
152
+ if (item instanceof Set) return { $set: [...item] }
153
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
154
+ const record = item as Record<string, unknown>
155
+ return Object.fromEntries(
156
+ Object.keys(record)
157
+ .sort()
158
+ .map((key) => [key, record[key]])
159
+ )
160
+ }
161
+ return item
162
+ }) ?? 'undefined'
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Cuts text to at most `maxLength` user-visible characters, ellipsis included.
168
+ * Never splits an emoji or accented letter, and drops whitespace before the
169
+ * ellipsis. With `wordBoundary`, cuts at the last space unless that would leave
170
+ * nothing.
171
+ */
172
+ export function truncate(
173
+ text: string,
174
+ maxLength: number,
175
+ options: { ellipsis?: string; wordBoundary?: boolean } = {}
176
+ ): string {
177
+ const { ellipsis = '…', wordBoundary = false } = options
178
+ const characters = graphemes(text)
179
+ if (characters.length <= maxLength) return text
180
+
181
+ const ellipsisCharacters = graphemes(ellipsis)
182
+ const room = maxLength - ellipsisCharacters.length
183
+ if (room <= 0) return ellipsisCharacters.slice(0, maxLength).join('')
184
+
185
+ let cut = characters.slice(0, room).join('')
186
+ if (wordBoundary && !/^\s/.test(characters[room] ?? '')) {
187
+ const lastSpace = cut.search(/\s\S*$/)
188
+ if (lastSpace > 0) cut = cut.slice(0, lastSpace)
189
+ }
190
+ return cut.trimEnd() + ellipsis
191
+ }
192
+
193
+ /** Collapses every whitespace run (NBSP and newlines included) to one space and trims. */
194
+ export function normalizeWhitespace(text: string): string {
195
+ return text.replace(/\s+/g, ' ').trim()
196
+ }
197
+
198
+ /**
199
+ * Escapes text for literal use in a `RegExp`. `-` becomes `\x2d` so the result
200
+ * is also valid with the `u` and `v` flags.
201
+ */
202
+ export function escapeRegExp(text: string): string {
203
+ return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&').replace(/-/g, '\\x2d')
204
+ }
205
+
206
+ /** Lowercases and strips accents, so `'Ærø Café'` becomes `'aero cafe'`. */
207
+ export function foldForSearch(text: string): string {
208
+ return stripAccents(text).toLowerCase()
209
+ }
210
+
211
+ /**
212
+ * True when every whitespace-separated query term appears in at least one
213
+ * field, ignoring case and accents. An empty query matches everything.
214
+ */
215
+ export function matchesSearch(
216
+ query: string,
217
+ ...fields: (string | null | undefined)[]
218
+ ): boolean {
219
+ const haystack = foldForSearch(fields.filter(Boolean).join('\n'))
220
+ return foldForSearch(query)
221
+ .split(/\s+/)
222
+ .every((term) => haystack.includes(term))
223
+ }
224
+
225
+ const HAS_LETTER = /\p{L}/u
226
+
227
+ /**
228
+ * Up to `max` (default 2) uppercase letters for an avatar, or `''` when the
229
+ * value has no letters (phone numbers, `'—'`). One word gives its first
230
+ * letters (`'Ada'` → `'AD'`), several give first and last (`'Hans Kristoffer
231
+ * Hansen'` → `'HH'`). An email is read as its local part without the `+tag`:
232
+ * `'ada.lovelace+news@x.com'` → `'AL'`.
233
+ */
234
+ export function initials(
235
+ name: string | null | undefined,
236
+ options: { max?: number } = {}
237
+ ): string {
238
+ const { max = 2 } = options
239
+ const trimmed = name?.trim() ?? ''
240
+ const source = /^[^\s@]+@[^\s@]+$/.test(trimmed)
241
+ ? (trimmed.split('@')[0] ?? '').replace(/\+.*$/, '').replace(/[._-]+/g, ' ')
242
+ : trimmed
243
+ const words = source.split(/\s+/).filter((word) => HAS_LETTER.test(word))
244
+ const first = words[0]
245
+ if (!first) return ''
246
+ if (words.length === 1) return fromFirstLetter(first, max).toUpperCase()
247
+
248
+ // Keep the last word (the surname) when there are more words than letters.
249
+ const head = words.slice(0, Math.max(1, max - 1))
250
+ const picked =
251
+ words.length > max ? [...head, words.at(-1) ?? first].slice(0, max) : words
252
+ return picked
253
+ .map((word) => fromFirstLetter(word, 1))
254
+ .join('')
255
+ .toUpperCase()
256
+ }
257
+
258
+ /** `JSON.parse` that returns a `Result` instead of throwing. */
259
+ export function safeJsonParse<T = unknown>(
260
+ text: string
261
+ ): Result<T, SyntaxError> {
262
+ try {
263
+ return { ok: true, data: JSON.parse(text) as T, error: null }
264
+ } catch (error) {
265
+ return { ok: false, data: null, error: error as SyntaxError }
266
+ }
267
+ }
268
+
269
+ // `count` characters from the word's first letter, so brackets around a name
270
+ // are skipped but digits after it are kept (`'R2D2'` → `'R2'`).
271
+ function fromFirstLetter(word: string, count: number): string {
272
+ const characters = graphemes(word)
273
+ const start = characters.findIndex((character) => HAS_LETTER.test(character))
274
+ if (start === -1) return ''
275
+ return characters.slice(start, start + count).join('')
276
+ }
277
+
278
+ let segmenter: Intl.Segmenter | undefined
279
+
280
+ // Grapheme clusters when `Intl.Segmenter` exists, otherwise code points.
281
+ function graphemes(text: string): string[] {
282
+ if (typeof Intl === 'undefined' || !('Segmenter' in Intl)) return [...text]
283
+ segmenter ??= new Intl.Segmenter()
284
+ return Array.from(segmenter.segment(text), ({ segment }) => segment)
55
285
  }
56
286
 
57
287
  function normalizeIdInput(input: JsonValue | undefined): string {
58
288
  if (input === undefined) return 'undefined'
59
289
  if (input === null) return 'null'
60
290
  if (typeof input === 'string') {
61
- return decodeURIComponent(input).split('&').sort().join('&')
291
+ return safeDecodeURIComponent(input).split('&').sort().join('&')
62
292
  }
63
293
  if (typeof input === 'number' || typeof input === 'boolean') {
64
294
  return input.toString()
@@ -84,3 +314,11 @@ function sortJsonValue(value: JsonValue | undefined): JsonValue | null {
84
314
 
85
315
  return value
86
316
  }
317
+
318
+ function safeDecodeURIComponent(value: string): string {
319
+ try {
320
+ return decodeURIComponent(value)
321
+ } catch {
322
+ return value
323
+ }
324
+ }
@@ -74,15 +74,27 @@ type RawHandlerReturnType<T> = T extends (...args: any[]) => infer R ? R : never
74
74
 
75
75
  /** Get union of all raw return types from a cases object's values */
76
76
  type RawCasesReturnType<Cases> = {
77
- [K in keyof Cases]: Cases[K] extends undefined
77
+ [K in keyof Cases]: RawHandlerReturnType<Cases[K]>
78
+ }[keyof Cases]
79
+
80
+ /** Only required, defined handlers guarantee that a case is handled. */
81
+ type HandledCaseKeys<Cases> = {
82
+ // biome-ignore lint/complexity/noBannedTypes: Empty-object assignability detects optional keys and index signatures.
83
+ [K in keyof Cases]-?: {} extends Pick<Cases, K>
78
84
  ? never
79
- : RawHandlerReturnType<Cases[K]>
85
+ : undefined extends Cases[K]
86
+ ? never
87
+ : K
80
88
  }[keyof Cases]
81
89
 
82
90
  // ═══════════════════════════════════════════════════════════════════════════
83
91
  // String input mode types
84
92
  // ═══════════════════════════════════════════════════════════════════════════
85
93
 
94
+ // The exported case-map types below are annotation helpers for consumers who
95
+ // want to type reusable case maps. The typedSwitch overloads infer their own
96
+ // shapes and do not depend on these types.
97
+
86
98
  /** Cases for string input - all cases required */
87
99
  export type StringSwitchCases<T extends string, R> = {
88
100
  [K in T]: (value: K) => R | Promise<R>
@@ -97,88 +109,45 @@ export type PartialStringSwitchCases<T extends string, R> = {
97
109
  // Object input mode types
98
110
  // ═══════════════════════════════════════════════════════════════════════════
99
111
 
100
- /** Cases for object input with custom key - all cases required */
101
- export type ObjectSwitchCases<T, K extends keyof T, R> = T[K] extends string
102
- ? {
103
- [V in T[K]]: (value: Extract<T, Record<K, V>>) => R | Promise<R>
104
- }
112
+ /** Discriminant values supported in object mode. Cases are keyed by `${value}`. */
113
+ type Discriminant = string | number | boolean
114
+
115
+ /** Discriminant values of `V` whose case key (`${value}`) is in `Keys` */
116
+ type ValuesForKeys<V, Keys> = V extends Discriminant
117
+ ? `${V}` extends `${Keys & (string | number)}`
118
+ ? V
119
+ : never
105
120
  : never
106
121
 
122
+ type ObjectCases<T, K extends keyof T, R> = {
123
+ [V in Extract<T[K], Discriminant> as `${V}`]: (
124
+ value: Extract<T, Record<K, V>>
125
+ ) => R
126
+ }
127
+
128
+ type PartialObjectCases<T, K extends keyof T, R> = {
129
+ [V in Extract<T[K], Discriminant> as `${V}`]?: (
130
+ value: Extract<T, Record<K, V>>
131
+ ) => R
132
+ }
133
+
134
+ /** Cases for object input with custom key - all cases required */
135
+ export type ObjectSwitchCases<
136
+ T,
137
+ K extends keyof T,
138
+ R
139
+ > = T[K] extends Discriminant ? ObjectCases<T, K, R | Promise<R>> : never
140
+
107
141
  /** Partial cases for object input with custom key (when default is provided) */
108
142
  export type PartialObjectSwitchCases<
109
143
  T,
110
144
  K extends keyof T,
111
145
  R
112
- > = T[K] extends string
113
- ? {
114
- [V in T[K]]?: (value: Extract<T, Record<K, V>>) => R | Promise<R>
115
- }
116
- : never
146
+ > = T[K] extends Discriminant ? PartialObjectCases<T, K, R | Promise<R>> : never
117
147
 
118
148
  type Handler = (value: unknown) => unknown
119
149
  type CasesRecord = Record<string, Handler | undefined>
120
150
 
121
- function runSwitch(
122
- value: string | Record<string, unknown>,
123
- keyOrCases: string | CasesRecord,
124
- casesOrDefault?: CasesRecord | Handler,
125
- defaultCase?: Handler
126
- ): unknown {
127
- let key: string
128
- let cases: CasesRecord
129
- let defaultHandler: Handler | undefined
130
- let discriminantKey: string | undefined
131
-
132
- if (typeof value === 'string') {
133
- // String input: typedSwitch(string, cases, default?)
134
- key = value
135
- cases = keyOrCases as CasesRecord
136
- defaultHandler = casesOrDefault as Handler | undefined
137
- } else {
138
- // Object input: typedSwitch(obj, key, cases, default?)
139
- if (typeof keyOrCases !== 'string') {
140
- throw new Error(
141
- 'Invalid typedSwitch call: object mode requires a string discriminant key'
142
- )
143
- }
144
-
145
- discriminantKey = keyOrCases
146
- const discriminantValue = value[discriminantKey]
147
- if (typeof discriminantValue !== 'string') {
148
- throw new Error(
149
- `Invalid discriminant value for key "${discriminantKey}": expected string, received ${typeof discriminantValue}`
150
- )
151
- }
152
-
153
- key = discriminantValue
154
- cases = casesOrDefault as CasesRecord
155
- defaultHandler = defaultCase
156
- }
157
-
158
- const handler = cases[key]
159
-
160
- if (handler) {
161
- return handler(value)
162
- }
163
-
164
- if (defaultHandler) {
165
- return defaultHandler(value)
166
- }
167
-
168
- const availableCases = Object.keys(cases).join(', ')
169
- const availableCasesSuffix =
170
- availableCases.length > 0
171
- ? ` Available cases: ${availableCases}`
172
- : ' Available cases: (none)'
173
- const contextSuffix = discriminantKey
174
- ? ` (discriminant key: "${discriminantKey}")`
175
- : ''
176
-
177
- throw new Error(
178
- `Unhandled case: ${key}${contextSuffix}.${availableCasesSuffix}`
179
- )
180
- }
181
-
182
151
  // ═══════════════════════════════════════════════════════════════════════════
183
152
  // Function overloads
184
153
  // ═══════════════════════════════════════════════════════════════════════════
@@ -190,10 +159,11 @@ export function typedSwitch<
190
159
  >(value: T, cases: Cases): RawCasesReturnType<Cases>
191
160
 
192
161
  // String input - partial cases allowed (with default)
162
+ // The default handler only receives the cases not covered by `cases`.
193
163
  export function typedSwitch<
194
164
  T extends string,
195
165
  Cases extends { [K in T]?: (value: K) => unknown },
196
- DefaultCase extends (value: T) => unknown
166
+ DefaultCase extends (value: Exclude<T, HandledCaseKeys<Cases>>) => unknown
197
167
  >(
198
168
  value: T,
199
169
  cases: Cases,
@@ -202,19 +172,20 @@ export function typedSwitch<
202
172
 
203
173
  // Object input with key - all cases required (no default)
204
174
  export function typedSwitch<
205
- T extends Record<string, unknown>,
175
+ T extends object,
206
176
  K extends keyof T,
207
- TKey extends T[K] & string,
208
- Cases extends { [V in TKey]: (value: Extract<T, Record<K, V>>) => unknown }
177
+ Cases extends ObjectCases<T, K, unknown>
209
178
  >(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>
210
179
 
211
180
  // Object input with key - partial cases allowed (with default)
181
+ // The default handler only receives the union members not covered by `cases`.
212
182
  export function typedSwitch<
213
- T extends Record<string, unknown>,
183
+ T extends object,
214
184
  K extends keyof T,
215
- TKey extends T[K] & string,
216
- Cases extends { [V in TKey]?: (value: Extract<T, Record<K, V>>) => unknown },
217
- DefaultCase extends (value: T) => unknown
185
+ Cases extends PartialObjectCases<T, K, unknown>,
186
+ DefaultCase extends (
187
+ value: Exclude<T, Record<K, ValuesForKeys<T[K], HandledCaseKeys<Cases>>>>
188
+ ) => unknown
218
189
  >(
219
190
  value: T,
220
191
  key: K,
@@ -249,13 +220,7 @@ export function typedSwitch<
249
220
  Constraint,
250
221
  T extends Record<string, unknown> = Record<string, unknown>,
251
222
  K extends keyof T = keyof T
252
- >(
253
- value: T,
254
- key: K,
255
- cases: {
256
- [V in T[K] & string]: (value: Extract<T, Record<K, V>>) => Constraint
257
- }
258
- ): Constraint
223
+ >(value: T, key: K, cases: ObjectCases<T, K, Constraint>): Constraint
259
224
  export function typedSwitch<
260
225
  Constraint,
261
226
  T extends Record<string, unknown> = Record<string, unknown>,
@@ -263,11 +228,7 @@ export function typedSwitch<
263
228
  >(
264
229
  value: T,
265
230
  key: K,
266
- cases: {
267
- [V in T[K] & string]: (
268
- value: Extract<T, Record<K, V>>
269
- ) => Constraint | Promise<Constraint>
270
- }
231
+ cases: ObjectCases<T, K, Constraint | Promise<Constraint>>
271
232
  ): Constraint | Promise<Constraint>
272
233
 
273
234
  // Object input with key - partial cases allowed with return constraint (with default)
@@ -278,9 +239,7 @@ export function typedSwitch<
278
239
  >(
279
240
  value: T,
280
241
  key: K,
281
- cases: {
282
- [V in T[K] & string]?: (value: Extract<T, Record<K, V>>) => Constraint
283
- },
242
+ cases: PartialObjectCases<T, K, Constraint>,
284
243
  defaultCase: (value: T) => Constraint
285
244
  ): Constraint
286
245
  export function typedSwitch<
@@ -290,20 +249,92 @@ export function typedSwitch<
290
249
  >(
291
250
  value: T,
292
251
  key: K,
293
- cases: {
294
- [V in T[K] & string]?: (
295
- value: Extract<T, Record<K, V>>
296
- ) => Constraint | Promise<Constraint>
297
- },
252
+ cases: PartialObjectCases<T, K, Constraint | Promise<Constraint>>,
298
253
  defaultCase: (value: T) => Constraint | Promise<Constraint>
299
254
  ): Constraint | Promise<Constraint>
300
255
 
301
256
  // Implementation
302
257
  export function typedSwitch(
303
- value: string | Record<string, unknown>,
258
+ value: string | object,
304
259
  keyOrCases: string | CasesRecord,
305
260
  casesOrDefault?: CasesRecord | Handler,
306
261
  defaultCase?: Handler
307
262
  ): unknown {
308
- return runSwitch(value, keyOrCases, casesOrDefault, defaultCase)
263
+ let key: string
264
+ let cases: CasesRecord
265
+ let defaultHandler: Handler | undefined
266
+ let discriminantKey: string | undefined
267
+
268
+ if (typeof value === 'string') {
269
+ // String input: typedSwitch(string, cases, default?)
270
+ key = value
271
+ cases = keyOrCases as CasesRecord
272
+ defaultHandler = casesOrDefault as Handler | undefined
273
+ } else {
274
+ // Object input: typedSwitch(obj, key, cases, default?)
275
+ if (typeof keyOrCases !== 'string') {
276
+ throw new Error(
277
+ 'Invalid typedSwitch call: object mode requires a string discriminant key'
278
+ )
279
+ }
280
+
281
+ discriminantKey = keyOrCases
282
+ const discriminantValue = (value as Record<string, unknown>)[
283
+ discriminantKey
284
+ ]
285
+ if (
286
+ typeof discriminantValue !== 'string' &&
287
+ typeof discriminantValue !== 'number' &&
288
+ typeof discriminantValue !== 'boolean'
289
+ ) {
290
+ throw new Error(
291
+ `Invalid discriminant value for key "${discriminantKey}": expected string, number or boolean, received ${typeof discriminantValue}`
292
+ )
293
+ }
294
+
295
+ key = String(discriminantValue)
296
+ cases = casesOrDefault as CasesRecord
297
+ defaultHandler = defaultCase
298
+ }
299
+
300
+ // Only own properties count as cases - a key like 'toString' must not
301
+ // resolve to an inherited Object.prototype method.
302
+ const handler = Object.hasOwn(cases, key) ? cases[key] : undefined
303
+
304
+ if (handler) {
305
+ return handler(value)
306
+ }
307
+
308
+ if (defaultHandler) {
309
+ return defaultHandler(value)
310
+ }
311
+
312
+ const availableCases = Object.keys(cases).join(', ')
313
+ const availableCasesSuffix =
314
+ availableCases.length > 0
315
+ ? ` Available cases: ${availableCases}`
316
+ : ' Available cases: (none)'
317
+ const contextSuffix = discriminantKey
318
+ ? ` (discriminant key: "${discriminantKey}")`
319
+ : ''
320
+
321
+ throw new Error(
322
+ `Unhandled case: ${key}${contextSuffix}.${availableCasesSuffix}`
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Exhaustiveness helper for regular `switch` statements. Place it in the
328
+ * `default` branch: it only type-checks when every case has been handled, and
329
+ * throws if an unexpected value reaches it at runtime.
330
+ *
331
+ * @example
332
+ * switch (status) {
333
+ * case 'success': return 1
334
+ * case 'error': return 0
335
+ * default: return assertNever(status)
336
+ * }
337
+ */
338
+ export function assertNever(value: never, message?: string): never {
339
+ throw new Error(message ?? `Unexpected value: ${String(value)}`)
309
340
  }
@@ -0,0 +1,14 @@
1
+ export type MaybePromise<T> = T | Promise<T>
2
+
3
+ /** Union of an object type's values. */
4
+ export type ValueOf<T> = T[keyof T]
5
+
6
+ /** Union of an array or tuple's element types. */
7
+ export type ElementOf<T extends readonly unknown[]> = T[number]
8
+
9
+ export type NonEmptyArray<T> = [T, ...T[]]
10
+
11
+ /** Flattens intersections into one object type so hovers stay readable. */
12
+ export type Simplify<T> = { [K in keyof T]: T[K] } & {}
13
+
14
+ export type Nullable<T> = T | null | undefined