hanzio 2.2.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -7
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-6ragggbs.js +473 -0
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +22 -6
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/typedswitch/index.d.ts +55 -34
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +97 -2
- package/src/api-wrapper/README.md +57 -2
- package/src/api-wrapper/SKILL.md +256 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +532 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/typedswitch/README.md +55 -39
- package/src/typedswitch/index.ts +97 -83
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type ModuleExports = Record<string, unknown>
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `mock.module` that can be undone. Bun replaces a mocked module for the whole
|
|
5
|
+
* process and `mock.restore()` does not bring it back
|
|
6
|
+
* (https://github.com/oven-sh/bun/issues/7823), so one suite's stub silently
|
|
7
|
+
* leaks into every later suite.
|
|
8
|
+
*
|
|
9
|
+
* Mocks spread the module's real exports first, so exports you don't override
|
|
10
|
+
* keep working, and `clear()` re-registers the originals. Call it in
|
|
11
|
+
* `afterEach` or `afterAll`.
|
|
12
|
+
*
|
|
13
|
+
* Bun resolves specifiers relative to the file that registers the mock, so
|
|
14
|
+
* pass absolute paths or package names. A `resolve` function maps your own
|
|
15
|
+
* shorthand (an `@/` alias, a source-root-relative path) to absolute paths.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const moduleMocker = new ModuleMocker((specifier) =>
|
|
19
|
+
* specifier.startsWith('@/') ? path.resolve(srcDir, specifier.slice(2)) : specifier
|
|
20
|
+
* )
|
|
21
|
+
* await moduleMocker.mock('@/lib/posthog', () => ({ captureException: mock() }))
|
|
22
|
+
* afterAll(() => moduleMocker.clear())
|
|
23
|
+
*/
|
|
24
|
+
export class ModuleMocker {
|
|
25
|
+
private restorers: (() => void)[] = []
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly resolve: (specifier: string) => string = (specifier) =>
|
|
29
|
+
specifier
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
async mock(
|
|
33
|
+
specifier: string,
|
|
34
|
+
renderMocks: () => ModuleExports
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const { mock } = await import('bun:test')
|
|
37
|
+
const resolved = this.resolve(specifier)
|
|
38
|
+
const original = { ...(await import(resolved)) } as ModuleExports
|
|
39
|
+
const merged = { ...original, ...renderMocks() }
|
|
40
|
+
mock.module(resolved, () => merged)
|
|
41
|
+
this.restorers.push(() => {
|
|
42
|
+
mock.module(resolved, () => original)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clear(): void {
|
|
47
|
+
for (const restore of this.restorers) restore()
|
|
48
|
+
this.restorers = []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Mock, mock } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
export type PrismaMethodMock = Mock<(...args: unknown[]) => Promise<unknown>>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A Prisma client where every model delegate and every method is a Bun mock,
|
|
7
|
+
* created on first access, like `jest-mock-extended`'s `mockDeep()`.
|
|
8
|
+
* `$`-prefixed client methods (`$transaction`, `$queryRaw`) are mocks too.
|
|
9
|
+
*/
|
|
10
|
+
export type DeepMockedPrisma<Client> = {
|
|
11
|
+
[K in keyof Client]: Client[K] extends (...args: never[]) => unknown
|
|
12
|
+
? PrismaMethodMock
|
|
13
|
+
: { [Method in keyof Client[K]]: PrismaMethodMock }
|
|
14
|
+
} & {
|
|
15
|
+
/** Resets every mock created so far. Call it in `beforeEach`. */
|
|
16
|
+
$reset: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds a deep auto-mock of a Prisma client from Bun's `mock`. Register it
|
|
21
|
+
* with `mock.module` in place of the module that exports your client.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* import { mock } from 'bun:test'
|
|
25
|
+
* import type { prisma } from '../lib/prisma'
|
|
26
|
+
*
|
|
27
|
+
* export const prismaMock = createPrismaMock<typeof prisma>(mock)
|
|
28
|
+
* mock.module(prismaModulePath, () => ({ ...prismaExports, prisma: prismaMock }))
|
|
29
|
+
*
|
|
30
|
+
* prismaMock.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
31
|
+
* expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ where: { id: 'u1' } })
|
|
32
|
+
*/
|
|
33
|
+
export function createPrismaMock<Client>(
|
|
34
|
+
createMock: typeof mock
|
|
35
|
+
): DeepMockedPrisma<Client> {
|
|
36
|
+
const created: PrismaMethodMock[] = []
|
|
37
|
+
const reset = () => {
|
|
38
|
+
for (const method of created) method.mockReset()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const methodsOf = (methods: Map<string, PrismaMethodMock>, key: string) => {
|
|
42
|
+
let method = methods.get(key)
|
|
43
|
+
if (!method) {
|
|
44
|
+
method = createMock() as unknown as PrismaMethodMock
|
|
45
|
+
created.push(method)
|
|
46
|
+
methods.set(key, method)
|
|
47
|
+
}
|
|
48
|
+
return method
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const delegates = new Map<string, object>()
|
|
52
|
+
const rootMethods = new Map<string, PrismaMethodMock>()
|
|
53
|
+
|
|
54
|
+
const delegateFor = (model: string) => {
|
|
55
|
+
let delegate = delegates.get(model)
|
|
56
|
+
if (!delegate) {
|
|
57
|
+
const methods = new Map<string, PrismaMethodMock>()
|
|
58
|
+
delegate = new Proxy(
|
|
59
|
+
{},
|
|
60
|
+
{
|
|
61
|
+
get: (_target, method) =>
|
|
62
|
+
typeof method === 'symbol' ? undefined : methodsOf(methods, method)
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
delegates.set(model, delegate)
|
|
66
|
+
}
|
|
67
|
+
return delegate
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new Proxy({} as DeepMockedPrisma<Client>, {
|
|
71
|
+
get(_target, property) {
|
|
72
|
+
if (property === '$reset') return reset
|
|
73
|
+
if (typeof property === 'symbol') return undefined
|
|
74
|
+
if (property.startsWith('$')) return methodsOf(rootMethods, property)
|
|
75
|
+
return delegateFor(property)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** A complete in-memory `Storage` (`localStorage` / `sessionStorage`). */
|
|
2
|
+
export function createTestStorage(): Storage {
|
|
3
|
+
const store = new Map<string, string>()
|
|
4
|
+
return {
|
|
5
|
+
get length() {
|
|
6
|
+
return store.size
|
|
7
|
+
},
|
|
8
|
+
clear: () => store.clear(),
|
|
9
|
+
getItem: (key) => store.get(key) ?? null,
|
|
10
|
+
key: (index) => [...store.keys()][index] ?? null,
|
|
11
|
+
removeItem: (key) => {
|
|
12
|
+
store.delete(key)
|
|
13
|
+
},
|
|
14
|
+
setItem: (key, value) => {
|
|
15
|
+
store.set(key, String(value))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ShimLocation = Pick<
|
|
21
|
+
URL,
|
|
22
|
+
| 'href'
|
|
23
|
+
| 'origin'
|
|
24
|
+
| 'protocol'
|
|
25
|
+
| 'host'
|
|
26
|
+
| 'hostname'
|
|
27
|
+
| 'port'
|
|
28
|
+
| 'pathname'
|
|
29
|
+
| 'search'
|
|
30
|
+
| 'hash'
|
|
31
|
+
>
|
|
32
|
+
|
|
33
|
+
type ShimWindow = {
|
|
34
|
+
location: ShimLocation
|
|
35
|
+
addEventListener: (...args: unknown[]) => void
|
|
36
|
+
removeEventListener: (...args: unknown[]) => void
|
|
37
|
+
localStorage: Storage
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const noop = () => {}
|
|
41
|
+
|
|
42
|
+
function locationOf(href: string): ShimLocation {
|
|
43
|
+
const { origin, protocol, host, hostname, port, pathname, search, hash } =
|
|
44
|
+
new URL(href)
|
|
45
|
+
return {
|
|
46
|
+
href,
|
|
47
|
+
origin,
|
|
48
|
+
protocol,
|
|
49
|
+
host,
|
|
50
|
+
hostname,
|
|
51
|
+
port,
|
|
52
|
+
pathname,
|
|
53
|
+
search,
|
|
54
|
+
hash
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === 'object' && value !== null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Installs a minimal `globalThis.window` for unit tests of browser-facing code
|
|
64
|
+
* that only probes `window` (`location`, `localStorage`, event listeners), and
|
|
65
|
+
* returns a `reset` to call in `afterEach`.
|
|
66
|
+
*
|
|
67
|
+
* Tests may assign a partial `window`. The shim keeps its own shape and copies
|
|
68
|
+
* `location`, `localStorage` and the listener functions from the assigned
|
|
69
|
+
* object, because libraries such as axios and PostHog read those at import
|
|
70
|
+
* time. It installs no `document`: Vue and PrismJS treat one as a full DOM.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* // test-setup.ts, preloaded through bunfig.toml
|
|
74
|
+
* const resetWindow = installWindowShim()
|
|
75
|
+
* afterEach(resetWindow)
|
|
76
|
+
*/
|
|
77
|
+
export function installWindowShim(href = 'http://localhost/'): () => void {
|
|
78
|
+
const shim: ShimWindow = {
|
|
79
|
+
location: locationOf(href),
|
|
80
|
+
addEventListener: noop,
|
|
81
|
+
removeEventListener: noop,
|
|
82
|
+
localStorage: createTestStorage()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const assign = (value: unknown) => {
|
|
86
|
+
if (!isObject(value)) return
|
|
87
|
+
if (value.localStorage) shim.localStorage = value.localStorage as Storage
|
|
88
|
+
shim.location =
|
|
89
|
+
isObject(value.location) && typeof value.location.href === 'string'
|
|
90
|
+
? (value.location as ShimLocation)
|
|
91
|
+
: locationOf(href)
|
|
92
|
+
shim.addEventListener =
|
|
93
|
+
typeof value.addEventListener === 'function'
|
|
94
|
+
? (value.addEventListener as ShimWindow['addEventListener'])
|
|
95
|
+
: noop
|
|
96
|
+
shim.removeEventListener =
|
|
97
|
+
typeof value.removeEventListener === 'function'
|
|
98
|
+
? (value.removeEventListener as ShimWindow['removeEventListener'])
|
|
99
|
+
: noop
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const install = () => {
|
|
103
|
+
Object.defineProperty(globalThis, 'window', {
|
|
104
|
+
configurable: true,
|
|
105
|
+
enumerable: true,
|
|
106
|
+
get: () => shim,
|
|
107
|
+
set: assign
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
install()
|
|
112
|
+
return () => {
|
|
113
|
+
shim.location = locationOf(href)
|
|
114
|
+
shim.addEventListener = noop
|
|
115
|
+
shim.removeEventListener = noop
|
|
116
|
+
shim.localStorage = createTestStorage()
|
|
117
|
+
install()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -22,15 +22,16 @@ bun add typedswitch
|
|
|
22
22
|
- **Exhaustiveness checking** — TypeScript ensures all cases are handled at compile time
|
|
23
23
|
- **Discriminated union support** — Works with any discriminant key, not just `type`
|
|
24
24
|
- **Async-aware** — Async handlers preserve their `Promise` return type
|
|
25
|
+
- **Grouped cases** — Share one handler between several values, fully typed
|
|
25
26
|
- **Default handlers** — Handle remaining cases with a fallback
|
|
26
|
-
- **
|
|
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
|
-
###
|
|
32
|
+
### Plain Value Input
|
|
32
33
|
|
|
33
|
-
Switch on a string
|
|
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
|
|
247
|
+
### Enforcing a Return Type
|
|
206
248
|
|
|
207
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
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
|
-
### `
|
|
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
|
-
|
|
323
|
+
Returns cases that all call `handler`, for spreading into `typedSwitch` cases.
|
|
308
324
|
|
|
309
325
|
### `assertNever(value, message?)`
|
|
310
326
|
|
package/src/typedswitch/index.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supports two input modes:
|
|
5
5
|
*
|
|
6
|
-
* 1.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
//
|
|
176
|
+
// Plain value input - all cases required (no default)
|
|
156
177
|
export function typedSwitch<
|
|
157
|
-
T extends
|
|
158
|
-
Cases extends { [
|
|
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
|
-
//
|
|
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
|
|
165
|
-
Cases extends {
|
|
166
|
-
|
|
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:
|
|
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
|
|
269
|
-
//
|
|
270
|
-
key = value
|
|
238
|
+
if (typeof value !== 'object') {
|
|
239
|
+
// Plain value input: typedSwitch(value, cases, default?)
|
|
240
|
+
key = String(value)
|
|
271
241
|
cases = keyOrCases as CasesRecord
|
|
272
242
|
defaultHandler = casesOrDefault as Handler | undefined
|
|
273
243
|
} else {
|
|
@@ -323,6 +293,50 @@ export function typedSwitch(
|
|
|
323
293
|
)
|
|
324
294
|
}
|
|
325
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Builds cases that share one handler, for spreading into `typedSwitch` cases.
|
|
298
|
+
* Pass the switched value so its type is inferred; the handler receives the
|
|
299
|
+
* union of the grouped members. A case listed after the spread replaces the
|
|
300
|
+
* grouped handler for that key; listing it before the spread is a type error.
|
|
301
|
+
* If two groups list the same key, the later group wins.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* typedSwitch(event, 'type', {
|
|
305
|
+
* ...typedSwitchGroup(event, 'type', ['click', 'scroll'], (e) => e.type),
|
|
306
|
+
* key: (e) => e.key,
|
|
307
|
+
* })
|
|
308
|
+
*/
|
|
309
|
+
export function typedSwitchGroup<
|
|
310
|
+
T extends Discriminant,
|
|
311
|
+
const Keys extends readonly T[],
|
|
312
|
+
R
|
|
313
|
+
>(
|
|
314
|
+
value: T,
|
|
315
|
+
keys: Keys,
|
|
316
|
+
handler: (value: Keys[number]) => R
|
|
317
|
+
): { [V in Keys[number] as `${V}`]: (value: V) => R }
|
|
318
|
+
export function typedSwitchGroup<
|
|
319
|
+
T extends object,
|
|
320
|
+
K extends keyof T,
|
|
321
|
+
const Keys extends readonly Extract<T[K], Discriminant>[],
|
|
322
|
+
R
|
|
323
|
+
>(
|
|
324
|
+
value: T,
|
|
325
|
+
key: K,
|
|
326
|
+
keys: Keys,
|
|
327
|
+
handler: (value: Extract<T, Record<K, Keys[number]>>) => R
|
|
328
|
+
): { [V in Keys[number] as `${V}`]: (value: Extract<T, Record<K, V>>) => R }
|
|
329
|
+
export function typedSwitchGroup(
|
|
330
|
+
_value: unknown,
|
|
331
|
+
keyOrKeys: unknown,
|
|
332
|
+
keysOrHandler: unknown,
|
|
333
|
+
handler?: Handler
|
|
334
|
+
): CasesRecord {
|
|
335
|
+
const keys = (handler ? keysOrHandler : keyOrKeys) as Discriminant[]
|
|
336
|
+
const shared = handler ?? (keysOrHandler as Handler)
|
|
337
|
+
return Object.fromEntries(keys.map((key) => [String(key), shared]))
|
|
338
|
+
}
|
|
339
|
+
|
|
326
340
|
/**
|
|
327
341
|
* Exhaustiveness helper for regular `switch` statements. Place it in the
|
|
328
342
|
* `default` branch: it only type-checks when every case has been handled, and
|
package/src/zod/index.ts
CHANGED
|
@@ -97,6 +97,26 @@ export const zJsonObject: z.ZodType<JsonObject> = z.record(
|
|
|
97
97
|
zJsonValue
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
+
/** An E.164 phone number: `+`, then 7 to 15 digits with no leading zero. */
|
|
101
|
+
export const E164_REGEX = /^\+[1-9]\d{6,14}$/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* An optional E.164 phone number for forms: a cleared input submits `''`,
|
|
105
|
+
* which means "no number", so `''` and `null` both parse to `null`.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately not `union([regex, literal('')])`: that serializes the empty
|
|
108
|
+
* literal as `enum: [""]`, which Gemini function declarations reject.
|
|
109
|
+
*/
|
|
110
|
+
export const zOptionalE164 = z
|
|
111
|
+
.string()
|
|
112
|
+
.refine(
|
|
113
|
+
(value) => value === '' || E164_REGEX.test(value),
|
|
114
|
+
'Phone number must be in E.164 format (e.g. +14155551234)'
|
|
115
|
+
)
|
|
116
|
+
.describe('E.164 phone number (e.g. +14155551234); empty string clears it')
|
|
117
|
+
.nullable()
|
|
118
|
+
.transform((value) => value || null)
|
|
119
|
+
|
|
100
120
|
/** `JSON.parse` + `schema.safeParse` without throwing. */
|
|
101
121
|
export function parseJson<S extends z.ZodType>(
|
|
102
122
|
text: string,
|