hanzio 2.2.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.
package/README.md CHANGED
@@ -52,6 +52,15 @@ subpath export:
52
52
  `scripts/verify-exports.mjs` imports every entry above from the packed tarball
53
53
  in CI, so this table and `package.json#exports` can't drift apart.
54
54
 
55
+ ## Migrating to 3.0
56
+
57
+ - `typedSwitch<Constraint>(…)` is removed. The explicit type argument switched
58
+ off inference, so those calls checked neither exhaustiveness nor, in object
59
+ mode, the return type. Annotate the result instead:
60
+ `const result: HasId = typedSwitch(status, { … })`.
61
+ - `typedSwitch` rejects case keys that aren't values of the switched union. A
62
+ typo used to compile and fall through to the default handler.
63
+
55
64
  ## Migrating to 2.0
56
65
 
57
66
  - Subpath-only modules are no longer re-exported from `hanzio`. Change
@@ -479,12 +488,13 @@ Validators run in order: `globalValidator`, `validators[toState]`, then
479
488
 
480
489
  ### typedSwitch
481
490
 
482
- `typedSwitch` is an exhaustive switch helper for string unions and discriminated
483
- unions (string, number or boolean discriminants). `assertNever` covers regular
484
- `switch` statements. See the [typedSwitch docs](src/typedswitch/README.md).
491
+ `typedSwitch` is an exhaustive switch helper for string, number and boolean
492
+ values and for discriminated unions. `typedSwitchGroup` lets several cases share
493
+ one handler. `assertNever` covers regular `switch` statements. See the
494
+ [typedSwitch docs](src/typedswitch/README.md).
485
495
 
486
496
  ```ts
487
- import { assertNever, typedSwitch } from 'hanzio'
497
+ import { assertNever, typedSwitch, typedSwitchGroup } from 'hanzio'
488
498
 
489
499
  type Event =
490
500
  | { type: 'click'; x: number; y: number }
@@ -501,6 +511,11 @@ const text = typedSwitch(response as Response, 'code', {
501
511
  404: () => 'Not found'
502
512
  })
503
513
 
514
+ const event = { type: 'scroll', offset: 4 } as Event
515
+ typedSwitch(event, 'type', {
516
+ ...typedSwitchGroup(event, 'type', ['click', 'scroll'], (e) => e.type)
517
+ })
518
+
504
519
  function area(shape: Shape) {
505
520
  switch (shape.kind) {
506
521
  case 'circle':
package/dist/src/index.js CHANGED
@@ -90,8 +90,8 @@ function typedSwitch(value, keyOrCases, casesOrDefault, defaultCase) {
90
90
  let cases;
91
91
  let defaultHandler;
92
92
  let discriminantKey;
93
- if (typeof value === "string") {
94
- key = value;
93
+ if (typeof value !== "object") {
94
+ key = String(value);
95
95
  cases = keyOrCases;
96
96
  defaultHandler = casesOrDefault;
97
97
  } else {
@@ -119,6 +119,11 @@ function typedSwitch(value, keyOrCases, casesOrDefault, defaultCase) {
119
119
  const contextSuffix = discriminantKey ? ` (discriminant key: "${discriminantKey}")` : "";
120
120
  throw new Error(`Unhandled case: ${key}${contextSuffix}.${availableCasesSuffix}`);
121
121
  }
122
+ function typedSwitchGroup(_value, keyOrKeys, keysOrHandler, handler) {
123
+ const keys = handler ? keysOrHandler : keyOrKeys;
124
+ const shared = handler ?? keysOrHandler;
125
+ return Object.fromEntries(keys.map((key) => [String(key), shared]));
126
+ }
122
127
  function assertNever(value, message) {
123
128
  throw new Error(message ?? `Unexpected value: ${String(value)}`);
124
129
  }
@@ -1093,6 +1098,7 @@ export {
1093
1098
  typedFromEntries,
1094
1099
  typedKeys,
1095
1100
  typedSwitch,
1101
+ typedSwitchGroup,
1096
1102
  unwrapResult2 as unwrapResult,
1097
1103
  withAlpha,
1098
1104
  withTimeout2 as withTimeout
@@ -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
  */
@@ -95,30 +106,40 @@ type PartialObjectCases<T, K extends keyof T, R> = {
95
106
  export type ObjectSwitchCases<T, K extends keyof T, R> = T[K] extends Discriminant ? ObjectCases<T, K, R | Promise<R>> : never;
96
107
  /** Partial cases for object input with custom key (when default is provided) */
97
108
  export type PartialObjectSwitchCases<T, K extends keyof T, R> = T[K] extends Discriminant ? PartialObjectCases<T, K, R | Promise<R>> : never;
98
- export declare function typedSwitch<T extends string, Cases extends {
99
- [K in T]: (value: K) => unknown;
100
- }>(value: T, cases: Cases): RawCasesReturnType<Cases>;
101
- export declare function typedSwitch<T extends string, Cases extends {
102
- [K in T]?: (value: K) => unknown;
103
- }, DefaultCase extends (value: Exclude<T, HandledCaseKeys<Cases>>) => unknown>(value: T, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
104
- export declare function typedSwitch<T extends object, K extends keyof T, Cases extends ObjectCases<T, K, unknown>>(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>;
105
- export declare function typedSwitch<T extends object, K extends keyof T, Cases extends PartialObjectCases<T, K, unknown>, DefaultCase extends (value: Exclude<T, Record<K, ValuesForKeys<T[K], HandledCaseKeys<Cases>>>>) => unknown>(value: T, key: K, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
106
- export declare function typedSwitch<Constraint, T extends string = string>(value: T, cases: {
107
- [K in T]: (value: K) => Constraint;
108
- }): Constraint;
109
- export declare function typedSwitch<Constraint, T extends string = string>(value: T, cases: {
110
- [K in T]: (value: K) => Constraint | Promise<Constraint>;
111
- }): Constraint | Promise<Constraint>;
112
- export declare function typedSwitch<Constraint, T extends string = string>(value: T, cases: {
113
- [K in T]?: (value: K) => Constraint;
114
- }, defaultCase: (value: T) => Constraint): Constraint;
115
- export declare function typedSwitch<Constraint, T extends string = string>(value: T, cases: {
116
- [K in T]?: (value: K) => Constraint | Promise<Constraint>;
117
- }, defaultCase: (value: T) => Constraint | Promise<Constraint>): Constraint | Promise<Constraint>;
118
- export declare function typedSwitch<Constraint, T extends Record<string, unknown> = Record<string, unknown>, K extends keyof T = keyof T>(value: T, key: K, cases: ObjectCases<T, K, Constraint>): Constraint;
119
- export declare function typedSwitch<Constraint, T extends Record<string, unknown> = Record<string, unknown>, K extends keyof T = keyof T>(value: T, key: K, cases: ObjectCases<T, K, Constraint | Promise<Constraint>>): Constraint | Promise<Constraint>;
120
- export declare function typedSwitch<Constraint, T extends Record<string, unknown> = Record<string, unknown>, K extends keyof T = keyof T>(value: T, key: K, cases: PartialObjectCases<T, K, Constraint>, defaultCase: (value: T) => Constraint): Constraint;
121
- export declare function typedSwitch<Constraint, T extends Record<string, unknown> = Record<string, unknown>, K extends keyof T = keyof T>(value: T, key: K, cases: PartialObjectCases<T, K, Constraint | Promise<Constraint>>, defaultCase: (value: T) => Constraint | Promise<Constraint>): Constraint | Promise<Constraint>;
109
+ /**
110
+ * Rejects case keys that are not values of the switched union, so a typo
111
+ * fails to compile instead of silently falling through to the default.
112
+ */
113
+ type NoUnknownCases<Cases, Allowed extends string> = {
114
+ [P in keyof Cases as `${P & (string | number)}` extends Allowed ? never : P]: never;
115
+ };
116
+ export declare function typedSwitch<T extends Discriminant, Cases extends {
117
+ [V in T as `${V}`]: (value: V) => unknown;
118
+ } & NoUnknownCases<Cases, `${T}`>>(value: T, cases: Cases): RawCasesReturnType<Cases>;
119
+ export declare function typedSwitch<T extends Discriminant, Cases extends {
120
+ [V in T as `${V}`]?: (value: V) => unknown;
121
+ } & NoUnknownCases<Cases, `${T}`>, DefaultCase extends (value: Exclude<T, ValuesForKeys<T, HandledCaseKeys<Cases>>>) => unknown>(value: T, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
122
+ export declare function typedSwitch<T extends object, K extends keyof T, Cases extends ObjectCases<T, K, unknown> & NoUnknownCases<Cases, `${Extract<T[K], Discriminant>}`>>(value: T, key: K, cases: Cases): RawCasesReturnType<Cases>;
123
+ export declare function typedSwitch<T extends object, K extends keyof T, Cases extends PartialObjectCases<T, K, unknown> & NoUnknownCases<Cases, `${Extract<T[K], Discriminant>}`>, DefaultCase extends (value: Exclude<T, Record<K, ValuesForKeys<T[K], HandledCaseKeys<Cases>>>>) => unknown>(value: T, key: K, cases: Cases, defaultCase: DefaultCase): RawCasesReturnType<Cases> | RawHandlerReturnType<DefaultCase>;
124
+ /**
125
+ * Builds cases that share one handler, for spreading into `typedSwitch` cases.
126
+ * Pass the switched value so its type is inferred; the handler receives the
127
+ * union of the grouped members. A case listed after the spread replaces the
128
+ * grouped handler for that key; listing it before the spread is a type error.
129
+ * If two groups list the same key, the later group wins.
130
+ *
131
+ * @example
132
+ * typedSwitch(event, 'type', {
133
+ * ...typedSwitchGroup(event, 'type', ['click', 'scroll'], (e) => e.type),
134
+ * key: (e) => e.key,
135
+ * })
136
+ */
137
+ export declare function typedSwitchGroup<T extends Discriminant, const Keys extends readonly T[], R>(value: T, keys: Keys, handler: (value: Keys[number]) => R): {
138
+ [V in Keys[number] as `${V}`]: (value: V) => R;
139
+ };
140
+ export declare function typedSwitchGroup<T extends object, K extends keyof T, const Keys extends readonly Extract<T[K], Discriminant>[], R>(value: T, key: K, keys: Keys, handler: (value: Extract<T, Record<K, Keys[number]>>) => R): {
141
+ [V in Keys[number] as `${V}`]: (value: Extract<T, Record<K, V>>) => R;
142
+ };
122
143
  /**
123
144
  * Exhaustiveness helper for regular `switch` statements. Place it in the
124
145
  * `default` branch: it only type-checks when every case has been handled, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanzio",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "A lightweight set of utility functions for TypeScript",
5
5
  "type": "module",
6
6
  "module": "./dist/src/index.js",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src/**/*.ts",
28
28
  "src/**/README.md",
29
+ "src/api-wrapper/SKILL.md",
29
30
  "dist",
30
31
  "!src/**/*.test.ts"
31
32
  ],
@@ -3,8 +3,11 @@
3
3
  Typed HTTP clients from Zod schemas: `createApiClient` validates requests and
4
4
  responses, retries with exponential backoff, and throws structured errors.
5
5
 
6
- Building a new wrapper with an AI agent? See [AGENTS.md](./AGENTS.md) for the
7
- step-by-step workflow and folder conventions.
6
+ Building a new wrapper with an AI agent? [SKILL.md](./SKILL.md) has the
7
+ step-by-step workflow and folder conventions. It ships with the package: copy
8
+ `node_modules/hanzio/src/api-wrapper/SKILL.md` to
9
+ `.claude/skills/hanzio-api-wrapper/SKILL.md` (or `.agents/skills/…`), or point
10
+ your agent at it.
8
11
 
9
12
  ## Quick Start
10
13
 
@@ -0,0 +1,256 @@
1
+ ---
2
+ name: hanzio-api-wrapper
3
+ description: Building typed HTTP API wrappers with createApiClient and Zod in hanzio/api-wrapper. Covers folder layout, index factory, endpoint files with defineEndpoint, shared types, pagination with paginate, and common REST API integration patterns.
4
+ ---
5
+
6
+ # AI Agent Guide: Building API Wrappers
7
+
8
+ This guide is for AI assistants to follow when building API wrappers using the
9
+ `createApiClient` utility from `hanzio/api-wrapper`. The API reference
10
+ (configuration, errors, actions, retries, pagination) lives in
11
+ [README.md](./README.md).
12
+
13
+ ## Folder Structure
14
+
15
+ When creating a new API wrapper, follow this structure:
16
+
17
+ ```txt
18
+ src/api-wrapper/{api-name}/
19
+ ├── index.ts
20
+ ├── types.ts
21
+ ├── {endpointName}.ts
22
+ ├── {anotherEndpoint}.ts
23
+ └── utils/
24
+ ├── {utilName}.ts
25
+ └── ...
26
+ ```
27
+
28
+ ### Naming Conventions
29
+
30
+ - Folder name: lowercase, kebab-case, for example `github-api`, `stripe`, `template-persona`.
31
+ - Endpoint files: camelCase and action-oriented, for example `topicsGet.ts`, `usersCreate.ts`, `ordersUpdate.ts`.
32
+ - Utility files: camelCase and descriptive, for example `paginateResults.ts`, `templateGetAll.ts`.
33
+
34
+ ## Step-By-Step Process
35
+
36
+ ### Step 1: Gather Information
37
+
38
+ The user will typically provide one or more of:
39
+
40
+ 1. cURL command: extract method, URL, headers, and body.
41
+ 2. Request/response examples: use them to build Zod schemas.
42
+ 3. Chrome DevTools Network logs: extract request and response details.
43
+ 4. API documentation: reference schemas, auth, pagination, and endpoints.
44
+
45
+ ### Step 2: Create The Folder
46
+
47
+ ```bash
48
+ mkdir -p src/api-wrapper/{api-name}/utils
49
+ ```
50
+
51
+ ### Step 3: Create The Index File
52
+
53
+ The index file creates and exports the API client. When the token comes from
54
+ a secret store, create the client at module level and resolve the header
55
+ lazily with a `defaultHeaders` function; take the token as a parameter only
56
+ when callers supply it per instance.
57
+
58
+ ```ts
59
+ import { createApiClient } from 'hanzio/api-wrapper'
60
+ import { endpointOne } from './endpointOne'
61
+ import { endpointTwo } from './endpointTwo'
62
+
63
+ export const exampleApi = createApiClient({
64
+ name: 'exampleApi',
65
+ baseApiUrls: {
66
+ default: 'https://api.example.com'
67
+ },
68
+ defaultHeaders: () => ({
69
+ Authorization: `Bearer ${getSecret('EXAMPLE_API_TOKEN')}`
70
+ }),
71
+ endpoints: {
72
+ endpointOne,
73
+ endpointTwo
74
+ }
75
+ })
76
+ ```
77
+
78
+ If the project uses OpenTelemetry, add
79
+ `use: [otelMiddleware(trace.getTracer('app'))]`; for other shared middleware
80
+ (metrics), pass it with `use: [...]` instead of wrapping `fetch`. Pass a
81
+ `logger` so failures are logged with their category (server, input or output
82
+ validation). If the API returns HTTP 200 with an
83
+ error envelope (e.g. `{ success: false, errors }`), add a `checkResponse` that
84
+ throws on failure rather than checking after every call. Endpoints without the
85
+ envelope set `checkResponse: false` (or their own check). When the result is in
86
+ response headers (e.g. a `Location`), read them in `resFormatter(body, headers)`.
87
+
88
+ ### Step 4: Create Shared Types
89
+
90
+ If multiple endpoints share schemas, create a `types.ts` file.
91
+
92
+ ```ts
93
+ import { z } from 'zod'
94
+
95
+ export const ListQueryInput = z.object({
96
+ page: z.number().optional(),
97
+ page_size: z.number().optional()
98
+ })
99
+ export type ListQueryInput = z.infer<typeof ListQueryInput>
100
+
101
+ export const ListQueryOutput = <T extends z.ZodType>(itemSchema: T) =>
102
+ z.object({
103
+ data: z.array(itemSchema),
104
+ current_page: z.number(),
105
+ last_page: z.number(),
106
+ per_page: z.number(),
107
+ total: z.number()
108
+ })
109
+
110
+ export type ListQueryOutput<T> = {
111
+ data: T[]
112
+ current_page: number
113
+ last_page: number
114
+ per_page: number
115
+ total: number
116
+ }
117
+ ```
118
+
119
+ ### Step 5: Create Endpoint Files
120
+
121
+ Each endpoint gets its own file with request/response schemas and endpoint
122
+ configuration.
123
+
124
+ ```ts
125
+ import { defineEndpoint } from 'hanzio/api-wrapper'
126
+ import { z } from 'zod'
127
+
128
+ export const UserResponse = z.object({
129
+ id: z.number(),
130
+ name: z.string(),
131
+ email: z.string()
132
+ })
133
+
134
+ export const usersGet = defineEndpoint({
135
+ method: 'GET',
136
+ path: '/users/:userId',
137
+ reqQuerySchema: z.object({ includeInactive: z.boolean().optional() }),
138
+ resSchema: UserResponse
139
+ })
140
+ ```
141
+
142
+ `defineEndpoint` types `reqParams` from the `:params` in `path`, so add a
143
+ `reqParamsSchema` only when params need validation or coercion.
144
+
145
+ ### Step 6: Create Utility Functions
146
+
147
+ Place reusable helpers in `utils/`, e.g. response normalization or rate-limit
148
+ handling. For pagination, use the built-in `paginate` instead of a hand-written
149
+ loop:
150
+
151
+ ```ts
152
+ import { paginate } from 'hanzio/api-wrapper'
153
+ import type { GetExampleApi } from '../index'
154
+
155
+ export const usersGetAll = (api: GetExampleApi) =>
156
+ Array.fromAsync(
157
+ paginate(
158
+ (page: number = 1) => api.usersList({ reqQuery: { page } }),
159
+ (res) =>
160
+ res.data.current_page < res.data.last_page
161
+ ? res.data.current_page + 1
162
+ : null
163
+ )
164
+ )
165
+ ```
166
+
167
+ ## Extracting Information From User Input
168
+
169
+ ### From cURL Commands
170
+
171
+ ```bash
172
+ curl -X POST 'https://api.example.com/users' \
173
+ -H 'Authorization: Bearer token123' \
174
+ -H 'Content-Type: application/json' \
175
+ -d '{"name": "John", "email": "john@example.com"}'
176
+ ```
177
+
178
+ Extract:
179
+
180
+ - Method: `POST`
181
+ - Base URL: `https://api.example.com`
182
+ - Path: `/users`
183
+ - Headers: `Authorization`, `Content-Type`
184
+ - Body schema: `{ name: string, email: string }`
185
+
186
+ ### From Chrome DevTools
187
+
188
+ Look for:
189
+
190
+ 1. Request URL: base URL plus path.
191
+ 2. Request method: HTTP method.
192
+ 3. Request headers: auth and custom headers.
193
+ 4. Request payload: body schema.
194
+ 5. Response body: response schema.
195
+ 6. Query string parameters: query schema.
196
+
197
+ ### Building Zod Schemas From JSON
198
+
199
+ Given this response:
200
+
201
+ ```json
202
+ {
203
+ "id": 123,
204
+ "name": "John Doe",
205
+ "email": "john@example.com",
206
+ "created_at": "2024-01-15T10:30:00Z",
207
+ "roles": ["admin", "user"],
208
+ "profile": {
209
+ "avatar_url": "https://example.com/avatar.png",
210
+ "bio": "Developer"
211
+ },
212
+ "is_active": true
213
+ }
214
+ ```
215
+
216
+ Create this schema:
217
+
218
+ ```ts
219
+ const UserResponse = z.object({
220
+ id: z.number(),
221
+ name: z.string(),
222
+ email: z.string(),
223
+ created_at: z.string(),
224
+ roles: z.array(z.string()),
225
+ profile: z.object({
226
+ avatar_url: z.string(),
227
+ bio: z.string()
228
+ }),
229
+ is_active: z.boolean()
230
+ })
231
+ ```
232
+
233
+ ## Checklist
234
+
235
+ - [ ] Create folder with API name in kebab-case.
236
+ - [ ] Create `index.ts` with a `createApiClient` call.
237
+ - [ ] Set `baseApiUrls` and `defaultHeaders` (a function when the token comes from a secret store).
238
+ - [ ] Add `checkResponse` if the API returns errors inside 200 responses.
239
+ - [ ] Create `types.ts` for shared schemas when needed.
240
+ - [ ] Create one file per endpoint.
241
+ - [ ] Define Zod schemas for all request and response data.
242
+ - [ ] Use `defineEndpoint` for endpoint definitions.
243
+ - [ ] Create helpers in `utils/` when needed.
244
+ - [ ] Export the API type, for example `export type ExampleApi = typeof exampleApi`.
245
+ - [ ] Test with `createMockFetch` from `hanzio/api-wrapper/testing` passed as `fetch`; don't replace `globalThis.fetch`.
246
+
247
+ ## Tips
248
+
249
+ 1. Always use `defineEndpoint` for endpoint definitions (it checks `resFormatter` and types path params).
250
+ 2. Import Zod from `zod`.
251
+ 3. Be strict with schemas and mark unstable fields as `.optional()` or `.nullish()`.
252
+ 4. Use descriptive endpoint names like `usersGet`, `usersCreate`, and `ordersListByStatus`.
253
+ 5. Keep files small by using one endpoint per file.
254
+ 6. Extract schemas to `types.ts` when used by two or more endpoints.
255
+ 7. Use `paginate` when APIs use pagination.
256
+ 8. Ask for sample responses if the user only provides cURL commands.
@@ -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