Skip to content

Commit eb5e823

Browse files
authored
Merge pull request #496 from traversable/valibot-defaultValue
feat(valibot): adds `vx.defaultValue`
2 parents 2b868ad + e483d4d commit eb5e823

File tree

8 files changed

+492
-1
lines changed

8 files changed

+492
-1
lines changed

.changeset/floppy-paths-stand.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@traversable/valibot": patch
3+
---
4+
5+
feat(valibot): adds `vx.defaultValue`

.changeset/little-taxis-dream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@traversable/zod": patch
3+
---
4+
5+
fix(zod): fixes `zx.defaultValue` error message to display the correct function name (`defaultValue`, not `withDefault`)

packages/valibot/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { vx } from '@traversable/valibot'
5757
- [`vx.deepClone.writeable`](https://github.com/traversable/schema/tree/main/packages/valibot#vxdeepclonewriteable)
5858
- [`vx.deepEqual`](https://github.com/traversable/schema/tree/main/packages/valibot#vxdeepequal)
5959
- [`vx.deepEqual.writeable`](https://github.com/traversable/schema/tree/main/packages/valibot#vxdeepequalwriteable)
60+
- [`vx.defaultValue`](https://github.com/traversable/schema/tree/main/packages/valibot#vxdefaultvalue)
6061
- [`vx.fromConstant`](https://github.com/traversable/schema/tree/main/packages/valibot#vxfromconstant)
6162
- [`vx.fromConstant.writeable`](https://github.com/traversable/schema/tree/main/packages/valibot#vxfromconstantwriteable)
6263
- [`vx.fromJson`](https://github.com/traversable/schema/tree/main/packages/valibot#vxfromjson)
@@ -354,6 +355,41 @@ console.log(deepEqual)
354355
#### See also
355356
- [`vx.deepEqual`](https://github.com/traversable/schema/tree/main/packages/valibot#vxdeepequal)
356357

358+
359+
### `vx.defaultValue`
360+
361+
`vx.defaultValues` converts a Valibot schema into a "default value' that respects the structure of the schema.
362+
363+
A common use case for `vx.defaultValue` is creating default values for forms.
364+
365+
> [!NOTE]
366+
> By default, `vx.defaultValue` does not make any assumptions about what "default" means for primitive types,
367+
> which is why it returns `undefined` when it encounters a leaf value. This behavior is configurable.
368+
369+
#### Example
370+
371+
```typescript
372+
import * as v from 'valibot'
373+
import { vx } from '@traversable/valibot'
374+
375+
const MySchema = v.object({
376+
a: v.number(),
377+
b: v.object({
378+
c: v.string(),
379+
d: v.array(v.boolean())
380+
})
381+
})
382+
383+
// by default, primitives are initialized as `undefined`:
384+
const defaultOne = vx.defaultValue(MySchema)
385+
console.log(defaultOne) // => { a: undefined, b: { c: undefined, d: [] } }
386+
387+
// to configure this behavior, use the `fallbacks` property:
388+
const defaultTwo = vx.defaultValue(MySchema, { fallbacks: { number: 0, string: '' } })
389+
console.log(defaultTwo) // => { a: 0, b: { c: '', d: [] } }
390+
```
391+
392+
357393
### `vx.fromConstant`
358394

359395
Convert a blob of JSON data into a valibot schema that represents the blob's least upper bound.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import * as v from 'valibot'
2+
import type { Primitive } from '@traversable/registry'
3+
import { fn, has, isPrimitive } from '@traversable/registry'
4+
import type { AnyTag, AnyValibotSchema } from '@traversable/valibot-types'
5+
import { isNullary, fold, tagged } from '@traversable/valibot-types'
6+
7+
export type Fixpoint =
8+
| undefined
9+
| readonly Fixpoint[]
10+
| { [x: string]: Fixpoint }
11+
12+
export type StructurePreservingFixpoint = { [K in keyof AnyTag]+?: unknown }
13+
14+
export type Hole<T> =
15+
| undefined
16+
| readonly T[]
17+
| { [x: string]: T }
18+
19+
export type Atom =
20+
| globalThis.Date
21+
| globalThis.RegExp
22+
23+
export type Fallbacks = { [K in AnyTag]+?: unknown }
24+
25+
export type UnionTreatment =
26+
| 'undefined'
27+
| 'preserveAll'
28+
| 'pickFirst'
29+
| (keyof any)[]
30+
31+
export type Options<Leaves extends Fallbacks = Fallbacks> = {
32+
fallbacks?: Leaves
33+
unionTreatment?: UnionTreatment
34+
}
35+
36+
const CATCH_ALL = undefined
37+
const NOT_NIL = (x: unknown) => x != null
38+
39+
const pathsAreEqual = (xs: (keyof any)[], ys: (keyof any)[]) => xs.length === ys.length && xs.every((x, i) => x === ys[i])
40+
41+
const pathIncludes = (longer: (keyof any)[], shorter: (keyof any)[]) => pathsAreEqual(
42+
longer.slice(0, shorter.length),
43+
shorter
44+
)
45+
46+
export type defaultValue<T, Fallback = undefined>
47+
= T extends Primitive | Atom ? T | Fallback
48+
: T extends Set<any> ? Set<defaultValue<ReturnType<(ReturnType<T['values']>['return'] & {})>['value'] & {}, Fallback>>
49+
: T extends Map<any, any> ? Map<
50+
defaultValue<({} & ReturnType<{} & ReturnType<T['entries']>['return']>['value'])[0], Fallback>,
51+
defaultValue<({} & ReturnType<{} & ReturnType<T['entries']>['return']>['value'])[1], Fallback>
52+
>
53+
: { [K in keyof T]-?: defaultValue<T[K], Fallback> }
54+
55+
/**
56+
* ## {@link defaultValue `vx.defaultValue`}
57+
*
58+
* Derive a default value from a Valibot schema.
59+
*
60+
* By default, {@link defaultValue `vx.defaultValue`} returns
61+
* `undefined` for primitive values.
62+
*
63+
* If you'd like to change that behavior, you can pass a set
64+
* of fallbacks via {@link Options `Options['fallbacks']`}
65+
*
66+
* Unions are special cases -- by default,
67+
* {@link defaultValue `vx.defaultValue`} simply picks the first
68+
* union, and generates a default value for it. You can configure
69+
* this behavior via {@link Options `Options['unionTreatment']`}.
70+
*
71+
* @example
72+
* import * as vi from 'vitest'
73+
* import * as v from 'valibot'
74+
* import { vx } from '@traversable/valibot'
75+
*
76+
* const MySchema = v.object({
77+
* abc: v.tuple([
78+
* v.literal(123),
79+
* v.set(
80+
* v.array(v.number())
81+
* )
82+
* ]),
83+
* def: v.string(),
84+
* ghi: v.number(),
85+
* jkl: v.boolean(),
86+
* mno: v.optional(v.object({
87+
* pqr: v.record(
88+
* v.enum(['P', 'Q', 'R']),
89+
* v.number()
90+
* ),
91+
* }))
92+
* })
93+
*
94+
* vi.assert.deepEqual(
95+
* vx.defaultValue(MySchema),
96+
* {
97+
* abc: [123, new Set([[]])],
98+
* def: undefined,
99+
* ghi: undefined,
100+
* jkl: undefined,
101+
* mno: {
102+
* pqr: {
103+
* P: undefined,
104+
* Q: undefined,
105+
* R: undefined
106+
* }
107+
* }
108+
* }
109+
* )
110+
*
111+
* vi.assert.deepEqual(
112+
* vx.defaultValue(
113+
* MySchema,
114+
* {
115+
* fallbacks: {
116+
* number: 0,
117+
* boolean: false,
118+
* string: ''
119+
* }
120+
* }
121+
* ),
122+
* {
123+
* abc: [123, new Set([[]])],
124+
* def: '',
125+
* ghi: 0,
126+
* jkl: false,
127+
* mno: {
128+
* pqr: { P: 0, Q: 0, R: 0 }
129+
* }
130+
* }
131+
* )
132+
*/
133+
134+
export function defaultValue<T extends AnyValibotSchema>(type: T): defaultValue<v.InferOutput<T>>
135+
export function defaultValue<T extends AnyValibotSchema, Leaves extends Fallbacks>(type: T, options: Options<Leaves>): defaultValue<v.InferOutput<T>, Leaves[keyof Leaves]>
136+
export function defaultValue<T extends AnyValibotSchema>(
137+
schema: T, {
138+
fallbacks = defaultValue.defaults.fallbacks,
139+
unionTreatment = defaultValue.defaults.unionTreatment,
140+
}: Options = defaultValue.defaults
141+
) {
142+
const path = Array.isArray(unionTreatment) ? unionTreatment : []
143+
144+
return fold<Fixpoint>((x, ix) => {
145+
switch (true) {
146+
default: return fn.exhaustive(x)
147+
case tagged('enum')(x): return x.options ?? CATCH_ALL
148+
case tagged('literal')(x): return x.literal ?? CATCH_ALL
149+
case isNullary(x): return fallbacks[x.type] ?? CATCH_ALL
150+
case tagged('custom')(x): return CATCH_ALL
151+
case tagged('optional')(x): return x.wrapped ?? CATCH_ALL
152+
case tagged('exactOptional')(x): return x.wrapped ?? CATCH_ALL
153+
case tagged('nonOptional')(x): return x.wrapped ?? CATCH_ALL
154+
case tagged('nullable')(x): return x.wrapped ?? CATCH_ALL
155+
case tagged('nonNullable')(x): return x.wrapped ?? CATCH_ALL
156+
case tagged('nullish')(x): return x.wrapped ?? CATCH_ALL
157+
case tagged('nonNullish')(x): return x.wrapped ?? CATCH_ALL
158+
case tagged('undefinedable')(x): return x.wrapped ?? CATCH_ALL
159+
case tagged('object')(x):
160+
case tagged('looseObject')(x):
161+
case tagged('strictObject')(x):
162+
case tagged('objectWithRest')(x): return x.entries
163+
case tagged('tuple')(x):
164+
case tagged('looseTuple')(x):
165+
case tagged('strictTuple')(x):
166+
case tagged('tupleWithRest')(x): return x.items
167+
case tagged('array')(x): return !isPrimitive(x.item) ? [x.item] : Array.of<Fixpoint>()
168+
case tagged('set')(x): return new Set(NOT_NIL(x.value) ? [x.value] : [])
169+
case tagged('map')(x): return new Map(NOT_NIL(x.key) && NOT_NIL(x.value) ? [[x.key, x.value]] : [])
170+
case tagged('lazy')(x): return x.getter() ?? CATCH_ALL
171+
case tagged('intersect')(x): return Object.assign({}, ...x.options)
172+
case tagged('record')(x): return !x.key || typeof x.key !== 'object' ? {} : fn.pipe(
173+
Object.values(x.key),
174+
(value) => fn.map(value, (k) => [k, x.value ?? CATCH_ALL]),
175+
Object.fromEntries
176+
)
177+
case tagged('union')(x):
178+
case tagged('variant')(x): {
179+
if (path.length > 0 && pathIncludes(path, ix.path)) {
180+
const index = path[ix.path.length + 1]
181+
if (index !== undefined && has(index)(x.options)) return x.options[index]
182+
else return CATCH_ALL
183+
}
184+
return unionTreatment === 'undefined' ? CATCH_ALL
185+
: unionTreatment === 'preserveAll' ? x.options
186+
: x.options.find(NOT_NIL) ?? CATCH_ALL
187+
}
188+
case tagged('promise')(x): return import('@traversable/valibot-types').then(({ Invariant }) => Invariant.Unimplemented('promise', 'defaultValue'))
189+
}
190+
})(schema)
191+
}
192+
193+
defaultValue.defaults = {
194+
unionTreatment: 'pickFirst',
195+
fallbacks: {
196+
any: undefined,
197+
bigint: undefined,
198+
boolean: undefined,
199+
date: undefined,
200+
file: undefined,
201+
literal: undefined,
202+
nan: undefined,
203+
never: undefined,
204+
null: undefined,
205+
number: undefined,
206+
string: undefined,
207+
symbol: undefined,
208+
undefined: undefined,
209+
unknown: undefined,
210+
void: undefined,
211+
}
212+
} satisfies Required<Options>

packages/valibot/src/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ export { VERSION } from './version.js'
1818
export { check } from './check.js'
1919
export { deepClone } from './deep-clone.js'
2020
export { deepEqual } from './deep-equal.js'
21+
export { defaultValue } from './default-value.js'
2122
export { fromConstant, fromJson } from './json.js'
2223
export { toType } from './to-type.js'

0 commit comments

Comments
 (0)