Skip to content

Commit 24dd0c3

Browse files
committed
fix(experimental): allow partial object defaults with optional
Parent `v.object(..., { optional: true, default: {} })` no longer requires every nested field to be listed in the default. Also expose `optional` on base type options so helpers like `v.number({ optional: true })` accept it cleanly.
1 parent eb6700f commit 24dd0c3

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

packages/experimental/src/schema-dx.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,27 @@ describe("other vTypes type-arg + optional DX", () => {
4444
>();
4545
});
4646

47+
it("v.number accepts optional: true without a type param", () => {
48+
const field = v.number({ optional: true });
49+
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
50+
number | undefined
51+
>();
52+
});
53+
4754
it("v.number with output type param accepts optional: true", () => {
4855
const field = v.number<number>({ optional: true });
4956
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
5057
number | undefined
5158
>();
5259
});
5360

61+
it("v.boolean/date accept optional: true", () => {
62+
const b = v.boolean({ optional: true });
63+
const d = v.date({ optional: true });
64+
expectTypeOf<InferOutput<typeof b>>().toEqualTypeOf<boolean | undefined>();
65+
expectTypeOf<InferOutput<typeof d>>().toEqualTypeOf<Date | undefined>();
66+
});
67+
5468
it("v.array with element accepts optional: true", () => {
5569
const field = v.array(v.string<"a" | "b">(), { optional: true });
5670
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
@@ -77,6 +91,57 @@ describe("other vTypes type-arg + optional DX", () => {
7791
});
7892
expectTypeOf<InferOutput<typeof arr>>().toEqualTypeOf<number[]>();
7993
});
94+
95+
it("object optional + empty default allows required children", () => {
96+
const field = v.object(
97+
{
98+
password: v.object(
99+
{
100+
hash: v.string({ optional: true }),
101+
verify: v.string({ optional: true }),
102+
},
103+
{ optional: true, default: {} },
104+
),
105+
minPasswordLength: v.number(),
106+
maxPasswordLength: v.number({ optional: true }),
107+
},
108+
{ optional: true, default: {} },
109+
);
110+
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<{
111+
password: { hash?: string; verify?: string };
112+
minPasswordLength: number;
113+
maxPasswordLength?: number;
114+
}>();
115+
});
116+
117+
it("password-options style extend with required number fields", () => {
118+
const options = v.var("options", { schema: v.object({}), default: {} });
119+
const passwordOptions = v.extend(options, {
120+
emailAndPassword: v.object(
121+
{
122+
password: v.object(
123+
{
124+
hash: v.fn.type({
125+
input: { password: v.string() },
126+
output: v.string(),
127+
optional: true,
128+
}),
129+
verify: v.fn.type({
130+
input: { password: v.string(), hash: v.string() },
131+
output: v.boolean(),
132+
optional: true,
133+
}),
134+
},
135+
{ optional: true, default: {} },
136+
),
137+
minPasswordLength: v.number(),
138+
maxPasswordLength: v.number(),
139+
},
140+
{ optional: true, default: {} },
141+
),
142+
});
143+
expectTypeOf(passwordOptions).not.toBeNever();
144+
});
80145
});
81146

82147
describe("factory defaults", () => {

packages/experimental/src/schema.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface TypeDefination<T, O, D = never> extends Rules {
4747

4848
export type TypeOptions<T, O> = {
4949
transform?: (value: T) => O;
50+
/** Accepted on every helper; overloads refine the output when `true`. */
51+
optional?: boolean;
5052
};
5153

5254
/**
@@ -815,13 +817,20 @@ type AnyFn = {
815817
<T = unknown>(options?: TypeOptions<T, T>): TypeDefination<T, T, never>;
816818
};
817819

820+
/**
821+
* Object defaults may be partial: nested fields still run through
822+
* validate (so their own defaults apply). An empty `{}` is always
823+
* fine at the type level - parent `optional` / `default` must not
824+
* demand that every required child be listed in the default.
825+
*/
826+
type ObjectDefault<S> = DefaultInput<Partial<ArgsShape<S>>>;
827+
818828
type ObjectFn = {
819829
<S, O = DefineOutput<S>>(
820830
shape: S,
821831
options: TypeOptions<DefineOutput<S>, O> & {
822832
optional: true;
823-
// Defaults are validated as inputs, so they use ArgsShape.
824-
default: DefaultInput<ArgsShape<S>>;
833+
default: ObjectDefault<S>;
825834
},
826835
): TypeDefination<ArgsShape<S>, O, ArgsShape<S>>;
827836
<S, O = DefineOutput<S>>(
@@ -831,7 +840,7 @@ type ObjectFn = {
831840
<S, O = DefineOutput<S>>(
832841
shape: S,
833842
options: TypeOptions<DefineOutput<S>, O> & {
834-
default: DefaultInput<ArgsShape<S>>;
843+
default: ObjectDefault<S>;
835844
},
836845
): TypeDefination<ArgsShape<S>, O, ArgsShape<S>>;
837846
<S, O = DefineOutput<S>>(

0 commit comments

Comments
 (0)