hanzio 1.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 (51) hide show
  1. package/README.md +510 -0
  2. package/dist/api-wrapper/index.d.ts +84 -0
  3. package/dist/api-wrapper/index.js +250 -0
  4. package/dist/array/index.d.ts +22 -0
  5. package/dist/cache/index.d.ts +19 -0
  6. package/dist/date/index.d.ts +11 -0
  7. package/dist/gzip/index.d.ts +2 -0
  8. package/dist/gzip/index.js +16 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.js +1565 -0
  11. package/dist/jwt/index.d.ts +3 -0
  12. package/dist/jwt/index.js +19 -0
  13. package/dist/math/index.d.ts +2 -0
  14. package/dist/math/linearRegressionTrend.d.ts +5 -0
  15. package/dist/math/metricsSeriesColors.d.ts +42 -0
  16. package/dist/p-queue/index.d.ts +95 -0
  17. package/dist/p-queue/index.js +439 -0
  18. package/dist/promise/index.d.ts +21 -0
  19. package/dist/sitemap/index.d.ts +14 -0
  20. package/dist/sitemap/index.js +552 -0
  21. package/dist/state/index.d.ts +101 -0
  22. package/dist/string/index.d.ts +8 -0
  23. package/dist/typedswitch/index.d.ts +124 -0
  24. package/dist/url/index.d.ts +1 -0
  25. package/dist/zod/index.d.ts +8 -0
  26. package/dist/zod/index.js +210 -0
  27. package/dist/zod/jsonToZodSchemaMapper.d.ts +6 -0
  28. package/dist/zod/zodMinMaxFilter.d.ts +14 -0
  29. package/dist/zod/zodToTypeString.d.ts +9 -0
  30. package/package.json +102 -0
  31. package/src/api-wrapper/index.ts +492 -0
  32. package/src/array/index.ts +187 -0
  33. package/src/cache/index.ts +154 -0
  34. package/src/date/index.ts +54 -0
  35. package/src/gzip/index.ts +29 -0
  36. package/src/index.ts +12 -0
  37. package/src/jwt/index.ts +25 -0
  38. package/src/math/index.ts +11 -0
  39. package/src/math/linearRegressionTrend.ts +36 -0
  40. package/src/math/metricsSeriesColors.ts +208 -0
  41. package/src/p-queue/index.ts +690 -0
  42. package/src/promise/index.ts +73 -0
  43. package/src/sitemap/index.ts +195 -0
  44. package/src/state/index.ts +331 -0
  45. package/src/string/index.ts +86 -0
  46. package/src/typedswitch/index.ts +309 -0
  47. package/src/url/index.ts +5 -0
  48. package/src/zod/index.ts +29 -0
  49. package/src/zod/jsonToZodSchemaMapper.ts +44 -0
  50. package/src/zod/zodMinMaxFilter.ts +28 -0
  51. package/src/zod/zodToTypeString.ts +222 -0
@@ -0,0 +1,492 @@
1
+ import { type z, ZodError } from 'zod'
2
+
3
+ export type HttpMethod =
4
+ | 'GET'
5
+ | 'POST'
6
+ | 'PUT'
7
+ | 'DELETE'
8
+ | 'PATCH'
9
+ | 'HEAD'
10
+ | 'OPTIONS'
11
+
12
+ export type RequestBodyFormat = 'json' | 'form-data'
13
+ export type BaseApiUrl = string | (() => string)
14
+ export type PathParams = Record<string, string | number>
15
+ export type QueryParams = Record<string, string | number | boolean | undefined>
16
+
17
+ export class ResponseValidationError extends Error {
18
+ public readonly zodError: ZodError
19
+ public readonly rawResponse: unknown
20
+ public readonly validationIssues: string
21
+
22
+ constructor(zodError: ZodError, rawResponse: unknown) {
23
+ const issues = zodError.issues
24
+ .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
25
+ .join('; ')
26
+
27
+ super(`Response validation failed: ${issues}`)
28
+ this.name = 'ResponseValidationError'
29
+ this.zodError = zodError
30
+ this.rawResponse = rawResponse
31
+ this.validationIssues = issues
32
+ }
33
+ }
34
+
35
+ export class HttpResponseError extends Error {
36
+ public readonly status: number
37
+ public readonly body: string
38
+
39
+ constructor(status: number, body: string) {
40
+ super(`HTTP error: ${status} - ${body}`)
41
+ this.name = 'HttpResponseError'
42
+ this.status = status
43
+ this.body = body
44
+ }
45
+ }
46
+
47
+ export interface ApiEndpoint<
48
+ TReqBody extends z.ZodType | undefined = z.ZodType | undefined,
49
+ TReqParams extends z.ZodType | undefined = z.ZodType | undefined,
50
+ TReqQuery extends z.ZodType | undefined = z.ZodType | undefined,
51
+ TReqHeaders extends z.ZodType | undefined = z.ZodType | undefined,
52
+ TResponse extends z.ZodType = z.ZodType
53
+ > {
54
+ method: HttpMethod
55
+ path: string | ((baseUrl: string) => string)
56
+ reqBodySchema?: TReqBody
57
+ reqParamsSchema?: TReqParams
58
+ reqQuerySchema?: TReqQuery
59
+ reqHeadersSchema?: TReqHeaders
60
+ resSchema: TResponse
61
+ baseApiUrl?: string
62
+ reqBodyFormat?: RequestBodyFormat
63
+ defaultHeaders?: Record<string, string>
64
+ resFormatter?: (
65
+ data: unknown,
66
+ headers: Record<string, string>
67
+ ) => z.infer<TResponse>
68
+ reqDefaultQueryParams?: QueryParams
69
+ doNotEncodeQueryParams?: boolean
70
+ }
71
+
72
+ export interface ApiWrapperResponse<T> {
73
+ data: T
74
+ requestSizeMb: number
75
+ responseSizeMb: number
76
+ requestSizeTotalMb: number
77
+ responseTimeMs: number
78
+ httpStatus: number
79
+ retryCount: number
80
+ }
81
+
82
+ export type RetryContext = {
83
+ error?: unknown
84
+ response?: Response
85
+ retryCount: number
86
+ maxRetries: number
87
+ }
88
+
89
+ export interface ApiClientConfig<T extends Record<string, ApiEndpoint>> {
90
+ name?: string
91
+ baseApiUrls: Record<string, BaseApiUrl>
92
+ defaultBaseApiUrl?: string
93
+ endpoints: T
94
+ defaultHeaders?: Record<string, string>
95
+ timeoutMs?: number
96
+ retries?: number
97
+ retryDelayMs?: number
98
+ shouldRetry?: (context: RetryContext) => boolean
99
+ logger?: Pick<Console, 'debug' | 'error'>
100
+ fetch?: typeof fetch
101
+ onRequestStart?: (context: { endpoint: string; url: string }) => void
102
+ onRequestEnd?: (context: {
103
+ endpoint: string
104
+ url: string
105
+ status: number
106
+ retryCount: number
107
+ responseTimeMs: number
108
+ }) => void
109
+ }
110
+
111
+ type RequestInput<TEndpoint extends ApiEndpoint> = {
112
+ reqBody?: InferOptionalSchema<TEndpoint['reqBodySchema']>
113
+ reqParams?: InferOptionalSchema<TEndpoint['reqParamsSchema']>
114
+ reqQuery?: InferOptionalSchema<TEndpoint['reqQuerySchema']>
115
+ reqHeaders?: InferOptionalSchema<TEndpoint['reqHeadersSchema']>
116
+ url?: string
117
+ }
118
+
119
+ type InferOptionalSchema<TSchema> = TSchema extends z.ZodType
120
+ ? z.infer<TSchema>
121
+ : undefined
122
+
123
+ type RequestConfig = {
124
+ method: HttpMethod
125
+ url: string
126
+ queryParams: Record<string, string>
127
+ body: unknown
128
+ headers: Record<string, string>
129
+ doNotEncodeQueryParams?: boolean
130
+ timeoutMs?: number
131
+ fetchFn: typeof fetch
132
+ }
133
+
134
+ export function createApiClient<T extends Record<string, ApiEndpoint>>(
135
+ apiConfig: ApiClientConfig<T>
136
+ ) {
137
+ const request = async <K extends keyof T>(
138
+ endpointKey: K,
139
+ input: RequestInput<T[K]> = {}
140
+ ): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>> => {
141
+ const endpoint = apiConfig.endpoints[endpointKey]
142
+
143
+ if (!endpoint) {
144
+ throw new Error(`Unknown API endpoint: ${String(endpointKey)}`)
145
+ }
146
+
147
+ const { finalUrl, fullUrl } = buildUrl(
148
+ endpoint,
149
+ input.reqParams as PathParams | undefined,
150
+ input.reqQuery as QueryParams | undefined,
151
+ apiConfig.baseApiUrls,
152
+ apiConfig.defaultBaseApiUrl,
153
+ input.url
154
+ )
155
+ const endpointName = apiConfig.name
156
+ ? `${apiConfig.name}.${String(endpointKey)}`
157
+ : String(endpointKey)
158
+ const startedAt = Date.now()
159
+ let retryCount = 0
160
+ let httpStatus = 0
161
+
162
+ apiConfig.onRequestStart?.({ endpoint: endpointName, url: fullUrl })
163
+
164
+ try {
165
+ const config: RequestConfig = {
166
+ method: endpoint.method,
167
+ url: finalUrl,
168
+ queryParams: buildQueryParams(
169
+ endpoint,
170
+ input.reqQuery as QueryParams | undefined
171
+ ),
172
+ body: buildRequestBody(endpoint, input.reqBody),
173
+ headers: buildHeaders(
174
+ endpoint,
175
+ input.reqHeaders as Record<string, string> | undefined,
176
+ apiConfig.defaultHeaders ?? {}
177
+ ),
178
+ doNotEncodeQueryParams: endpoint.doNotEncodeQueryParams,
179
+ timeoutMs: apiConfig.timeoutMs,
180
+ fetchFn: apiConfig.fetch ?? fetch
181
+ }
182
+
183
+ const result = await makeRequestWithRetry(
184
+ config,
185
+ apiConfig.retries ?? 3,
186
+ apiConfig.retryDelayMs ?? 300,
187
+ (response) => validateAndTransformResponse(endpoint, response),
188
+ apiConfig.shouldRetry ?? defaultShouldRetry,
189
+ apiConfig.logger
190
+ )
191
+
192
+ retryCount = result.retryCount
193
+ httpStatus = result.httpStatus
194
+
195
+ const responseTimeMs = Date.now() - startedAt
196
+ const responseSizeMb = calculateSizeInMb(result.validatedData)
197
+ const requestSizeMb = calculateSizeInMb({
198
+ method: config.method,
199
+ url: fullUrl,
200
+ body: config.body,
201
+ headers: config.headers
202
+ })
203
+
204
+ apiConfig.onRequestEnd?.({
205
+ endpoint: endpointName,
206
+ url: fullUrl,
207
+ status: httpStatus,
208
+ retryCount,
209
+ responseTimeMs
210
+ })
211
+
212
+ return {
213
+ data: result.validatedData as z.infer<T[K]['resSchema']>,
214
+ requestSizeMb,
215
+ responseSizeMb,
216
+ requestSizeTotalMb: requestSizeMb + responseSizeMb,
217
+ responseTimeMs,
218
+ httpStatus,
219
+ retryCount
220
+ }
221
+ } catch (error) {
222
+ apiConfig.logger?.error?.(error)
223
+ throw error
224
+ }
225
+ }
226
+
227
+ return { request }
228
+ }
229
+
230
+ export const createApiWrapper = createApiClient
231
+
232
+ async function validateResponse<T extends z.ZodType>(
233
+ schema: T,
234
+ data: unknown
235
+ ): Promise<z.infer<T>> {
236
+ try {
237
+ return await schema.parseAsync(data)
238
+ } catch (error) {
239
+ if (error instanceof ZodError) {
240
+ throw new ResponseValidationError(error, data)
241
+ }
242
+ throw error
243
+ }
244
+ }
245
+
246
+ async function httpRequest(config: RequestConfig): Promise<Response> {
247
+ const queryString = config.doNotEncodeQueryParams
248
+ ? Object.entries(config.queryParams)
249
+ .map(([key, value]) => `${key}=${value}`)
250
+ .join('&')
251
+ : new URLSearchParams(config.queryParams).toString()
252
+ const fullUrl = queryString ? `${config.url}?${queryString}` : config.url
253
+ const abortController = new AbortController()
254
+ const timeout =
255
+ config.timeoutMs === undefined
256
+ ? undefined
257
+ : setTimeout(() => abortController.abort(), config.timeoutMs)
258
+
259
+ try {
260
+ return await config.fetchFn(fullUrl, {
261
+ method: config.method,
262
+ headers: config.headers,
263
+ body: buildFetchBody(config.body, config.headers),
264
+ signal: abortController.signal
265
+ })
266
+ } finally {
267
+ if (timeout) clearTimeout(timeout)
268
+ }
269
+ }
270
+
271
+ async function makeRequestWithRetry<TValidated>(
272
+ config: RequestConfig,
273
+ maxRetries: number,
274
+ retryDelayMs: number,
275
+ validateFn: (response: Response) => Promise<TValidated>,
276
+ shouldRetry: (context: RetryContext) => boolean,
277
+ logger?: Pick<Console, 'debug'>
278
+ ): Promise<{
279
+ validatedData: TValidated
280
+ retryCount: number
281
+ httpStatus: number
282
+ }> {
283
+ let retryCount = 0
284
+
285
+ while (true) {
286
+ try {
287
+ logger?.debug?.('API request', config)
288
+ const response = await httpRequest(config)
289
+
290
+ if (response.status >= 400) {
291
+ if (
292
+ retryCount < maxRetries &&
293
+ shouldRetry({ response, retryCount, maxRetries })
294
+ ) {
295
+ retryCount++
296
+ await delay(retryDelayMs)
297
+ continue
298
+ }
299
+
300
+ throw new HttpResponseError(response.status, await response.text())
301
+ }
302
+
303
+ return {
304
+ validatedData: await validateFn(response),
305
+ retryCount,
306
+ httpStatus: response.status
307
+ }
308
+ } catch (error) {
309
+ if (error instanceof HttpResponseError) throw error
310
+
311
+ if (
312
+ retryCount >= maxRetries ||
313
+ !shouldRetry({ error, retryCount, maxRetries })
314
+ ) {
315
+ throw error
316
+ }
317
+
318
+ retryCount++
319
+ await delay(retryDelayMs)
320
+ }
321
+ }
322
+ }
323
+
324
+ async function validateAndTransformResponse<T extends z.ZodType>(
325
+ endpoint: ApiEndpoint<
326
+ z.ZodType | undefined,
327
+ z.ZodType | undefined,
328
+ z.ZodType | undefined,
329
+ z.ZodType | undefined,
330
+ T
331
+ >,
332
+ response: Response
333
+ ): Promise<z.infer<T>> {
334
+ const responseHeaders = getHeadersAsObject(response.headers)
335
+ const contentType = responseHeaders['content-type'] ?? ''
336
+ const responseData: unknown = contentType.includes('application/json')
337
+ ? await response.json()
338
+ : await response.text()
339
+ const transformedData = endpoint.resFormatter
340
+ ? endpoint.resFormatter(responseData, responseHeaders)
341
+ : responseData
342
+
343
+ return validateResponse(endpoint.resSchema, transformedData)
344
+ }
345
+
346
+ function buildUrl(
347
+ endpoint: ApiEndpoint,
348
+ params: PathParams | undefined,
349
+ reqQuery: QueryParams | undefined,
350
+ baseApiUrls: Record<string, BaseApiUrl>,
351
+ defaultBaseApiUrl?: string,
352
+ url?: string
353
+ ) {
354
+ if (url) {
355
+ const finalUrl = replacePathParams(url, params)
356
+ const queryString = new URLSearchParams(
357
+ buildQueryParams(endpoint, reqQuery)
358
+ ).toString()
359
+ return {
360
+ finalUrl,
361
+ fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
362
+ }
363
+ }
364
+
365
+ const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
366
+ if (!defaultBaseUrl) {
367
+ throw new Error('At least one base API URL is required')
368
+ }
369
+
370
+ const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
371
+ const configBaseUrl = baseApiUrls[baseUrlKey]
372
+ if (!configBaseUrl) {
373
+ throw new Error(`Unknown base API URL: ${baseUrlKey}`)
374
+ }
375
+
376
+ const baseUrl =
377
+ typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
378
+ const path =
379
+ typeof endpoint.path === 'function' ? endpoint.path(baseUrl) : endpoint.path
380
+ const finalUrl = replacePathParams(`${baseUrl}${path}`, params)
381
+ const queryString = new URLSearchParams(
382
+ buildQueryParams(endpoint, reqQuery)
383
+ ).toString()
384
+
385
+ return {
386
+ finalUrl,
387
+ fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
388
+ }
389
+ }
390
+
391
+ function buildQueryParams(
392
+ endpoint: ApiEndpoint,
393
+ query: QueryParams | undefined
394
+ ): Record<string, string> {
395
+ const defaultParams = endpoint.reqDefaultQueryParams ?? {}
396
+ const parsedQuery = endpoint.reqQuerySchema
397
+ ? (endpoint.reqQuerySchema.parse(query) as QueryParams)
398
+ : (query ?? {})
399
+ const merged = { ...defaultParams, ...parsedQuery }
400
+
401
+ return Object.fromEntries(
402
+ Object.entries(merged)
403
+ .filter(([, value]) => value !== undefined)
404
+ .map(([key, value]) => [key, String(value)])
405
+ )
406
+ }
407
+
408
+ function buildRequestBody(endpoint: ApiEndpoint, body: unknown): unknown {
409
+ if (!endpoint.reqBodySchema) return body
410
+
411
+ const parsedBody = endpoint.reqBodySchema.parse(body) as Record<
412
+ string,
413
+ unknown
414
+ >
415
+
416
+ if (endpoint.reqBodyFormat !== 'form-data') {
417
+ return parsedBody
418
+ }
419
+
420
+ const formData = new FormData()
421
+ for (const [key, value] of Object.entries(parsedBody)) {
422
+ if (value == null) continue
423
+ formData.set(key, value instanceof Blob ? value : String(value))
424
+ }
425
+
426
+ return formData
427
+ }
428
+
429
+ function buildHeaders(
430
+ endpoint: ApiEndpoint,
431
+ headers: Record<string, string> | undefined,
432
+ defaultHeaders: Record<string, string>
433
+ ): Record<string, string> {
434
+ const contentType =
435
+ endpoint.reqBodyFormat === 'json' ? 'application/json' : undefined
436
+ const parsedHeaders = endpoint.reqHeadersSchema
437
+ ? (endpoint.reqHeadersSchema.parse(headers) as Record<string, string>)
438
+ : (headers ?? {})
439
+
440
+ return {
441
+ ...defaultHeaders,
442
+ ...(contentType && { 'Content-Type': contentType }),
443
+ ...endpoint.defaultHeaders,
444
+ ...parsedHeaders
445
+ }
446
+ }
447
+
448
+ function buildFetchBody(
449
+ body: unknown,
450
+ headers: Record<string, string>
451
+ ): RequestInit['body'] {
452
+ if (body === undefined || body === null) return undefined
453
+ if (body instanceof FormData || body instanceof Blob) return body
454
+ if (headers['Content-Type'] === 'application/json')
455
+ return JSON.stringify(body)
456
+ return body as RequestInit['body']
457
+ }
458
+
459
+ function replacePathParams(
460
+ path: string,
461
+ params: PathParams | undefined
462
+ ): string {
463
+ return path.replace(/:(\w+)/g, (_, key: string) => {
464
+ const value = params?.[key]
465
+ if (value === undefined) {
466
+ throw new Error(`Missing required path parameter: ${key}`)
467
+ }
468
+ return encodeURIComponent(String(value))
469
+ })
470
+ }
471
+
472
+ function getHeadersAsObject(headers: Headers): Record<string, string> {
473
+ const result: Record<string, string> = {}
474
+ headers.forEach((value, key) => {
475
+ result[key] = value
476
+ })
477
+ return result
478
+ }
479
+
480
+ function calculateSizeInMb(data: unknown): number {
481
+ const sizeInBytes = new TextEncoder().encode(JSON.stringify(data)).length
482
+ return sizeInBytes / (1024 * 1024)
483
+ }
484
+
485
+ function defaultShouldRetry({ error, response }: RetryContext): boolean {
486
+ if (error) return true
487
+ return response ? response.status >= 500 : false
488
+ }
489
+
490
+ function delay(ms: number): Promise<void> {
491
+ return new Promise((resolve) => setTimeout(resolve, ms))
492
+ }
@@ -0,0 +1,187 @@
1
+ export interface ArrayDifference<TCurrent, TNext> {
2
+ new: TNext[]
3
+ upsert: TNext[]
4
+ delete: TCurrent[]
5
+ }
6
+
7
+ type PropertyKeyValue = string | number | symbol
8
+ type NonNullableValue<T> = T extends null | undefined ? never : T
9
+ type ItemsMatchingKeyValue<T, TKey extends keyof T, TValue> = [
10
+ Extract<T, Record<TKey, TValue>>
11
+ ] extends [never]
12
+ ? T
13
+ : Extract<T, Record<TKey, TValue>>
14
+
15
+ export function chunkArray<T>(array: readonly T[], chunkSize: number): T[][] {
16
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
17
+ throw new Error('Chunk size must be a positive integer')
18
+ }
19
+
20
+ const chunks: T[][] = []
21
+ for (let index = 0; index < array.length; index += chunkSize) {
22
+ chunks.push(array.slice(index, index + chunkSize))
23
+ }
24
+
25
+ return chunks
26
+ }
27
+
28
+ export function findArrayDifferenceByKey<
29
+ TCurrent,
30
+ TNext,
31
+ TKey extends keyof TCurrent & keyof TNext
32
+ >(
33
+ current: readonly TCurrent[],
34
+ next: readonly TNext[],
35
+ key: TKey
36
+ ): ArrayDifference<TCurrent, TNext> {
37
+ const currentByKey = new Map(
38
+ current.map((item) => [String(item[key]), item] as const)
39
+ )
40
+ const itemsToDelete = [...current]
41
+ const newItems: TNext[] = []
42
+ const upsertItems: TNext[] = []
43
+
44
+ for (const item of next) {
45
+ const keyValue = String(item[key])
46
+
47
+ if (!currentByKey.has(keyValue)) {
48
+ newItems.push(item)
49
+ continue
50
+ }
51
+
52
+ upsertItems.push(item)
53
+ const deleteIndex = itemsToDelete.findIndex(
54
+ (currentItem) => String(currentItem[key]) === keyValue
55
+ )
56
+ if (deleteIndex >= 0) {
57
+ itemsToDelete.splice(deleteIndex, 1)
58
+ }
59
+ }
60
+
61
+ return {
62
+ new: newItems,
63
+ upsert: upsertItems,
64
+ delete: itemsToDelete
65
+ }
66
+ }
67
+
68
+ export function getUniqueValues<T>(array: readonly T[]): T[] {
69
+ const seen = new Map<string, T>()
70
+
71
+ for (const element of array) {
72
+ const key = JSON.stringify(element)
73
+ if (!seen.has(key)) {
74
+ seen.set(key, element)
75
+ }
76
+ }
77
+
78
+ return Array.from(seen.values())
79
+ }
80
+
81
+ export function compact<T>(array: readonly T[]): NonNullableValue<T>[] {
82
+ return array.filter((item): item is NonNullableValue<T> => item != null)
83
+ }
84
+
85
+ export function getUniqueValuesByKey<T, TKey extends keyof T>(
86
+ array: readonly T[],
87
+ key: TKey
88
+ ): T[] {
89
+ const seen = new Set<T[TKey]>()
90
+ const result: T[] = []
91
+
92
+ for (const item of array) {
93
+ const value = item[key]
94
+ if (seen.has(value)) continue
95
+ seen.add(value)
96
+ result.push(item)
97
+ }
98
+
99
+ return result
100
+ }
101
+
102
+ export function groupBy<
103
+ T,
104
+ TKey extends keyof T,
105
+ TGroupKey extends Extract<T[TKey], PropertyKeyValue>
106
+ >(array: readonly T[], key: TKey): Record<TGroupKey, T[]>
107
+ export function groupBy<T, TGroupKey extends PropertyKeyValue>(
108
+ array: readonly T[],
109
+ keyFn: (item: T) => TGroupKey
110
+ ): Record<TGroupKey, T[]>
111
+ export function groupBy<T, TGroupKey extends PropertyKeyValue>(
112
+ array: readonly T[],
113
+ keyOrFn: keyof T | ((item: T) => TGroupKey)
114
+ ): Record<TGroupKey, T[]> {
115
+ return array.reduce(
116
+ (result, item) => {
117
+ const groupKey =
118
+ typeof keyOrFn === 'function'
119
+ ? keyOrFn(item)
120
+ : (item[keyOrFn] as TGroupKey)
121
+
122
+ result[groupKey] ??= []
123
+ result[groupKey].push(item)
124
+ return result
125
+ },
126
+ {} as Record<TGroupKey, T[]>
127
+ )
128
+ }
129
+
130
+ export function keyBy<
131
+ T,
132
+ TKey extends keyof T,
133
+ TRecordKey extends Extract<T[TKey], PropertyKeyValue>
134
+ >(array: readonly T[], key: TKey): Record<TRecordKey, T>
135
+ export function keyBy<T, TRecordKey extends PropertyKeyValue>(
136
+ array: readonly T[],
137
+ keyFn: (item: T) => TRecordKey
138
+ ): Record<TRecordKey, T>
139
+ export function keyBy<T, TRecordKey extends PropertyKeyValue>(
140
+ array: readonly T[],
141
+ keyOrFn: keyof T | ((item: T) => TRecordKey)
142
+ ): Record<TRecordKey, T> {
143
+ const result = {} as Record<TRecordKey, T>
144
+
145
+ for (const item of array) {
146
+ const recordKey =
147
+ typeof keyOrFn === 'function'
148
+ ? keyOrFn(item)
149
+ : (item[keyOrFn] as TRecordKey)
150
+ result[recordKey] = item
151
+ }
152
+
153
+ return result
154
+ }
155
+
156
+ export function partition<T>(
157
+ array: readonly T[],
158
+ predicate: (item: T, index: number) => boolean
159
+ ): [matched: T[], unmatched: T[]] {
160
+ const matched: T[] = []
161
+ const unmatched: T[] = []
162
+
163
+ array.forEach((item, index) => {
164
+ if (predicate(item, index)) {
165
+ matched.push(item)
166
+ } else {
167
+ unmatched.push(item)
168
+ }
169
+ })
170
+
171
+ return [matched, unmatched]
172
+ }
173
+
174
+ export function pickItemsInArray<
175
+ T,
176
+ TKey extends keyof T,
177
+ const TValue extends readonly T[TKey][]
178
+ >(
179
+ array: readonly T[],
180
+ key: TKey,
181
+ values: TValue
182
+ ): ItemsMatchingKeyValue<T, TKey, TValue[number]>[] {
183
+ return array.filter(
184
+ (item): item is ItemsMatchingKeyValue<T, TKey, TValue[number]> =>
185
+ values.includes(item[key])
186
+ )
187
+ }