Skip to content

Commit 01ff4b7

Browse files
authored
Merge pull request #200 from ping-maxwell/experimental
feat(experimental): allow null when schema is optional
2 parents ad4ad43 + 6eca0b5 commit 01ff4b7

3 files changed

Lines changed: 74 additions & 37 deletions

File tree

packages/experimental/src/fn.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ describe("omittable input", () => {
8888

8989
it("optional object omit is undefined - field defaults do not run", () => {
9090
// `{ optional: true }` on the object means "may be absent". An absent
91-
// payload stays undefined; size's default only applies when an object
92-
// was actually sent. InferInput must carry `| undefined` so
93-
// `c.input.size` is not typed as a bare number.
91+
// payload stays undefined/null; size's default only applies when an
92+
// object was actually sent. InferInput must carry `| undefined | null`
93+
// so `c.input.size` is not typed as a bare number.
9494
const generateId = v.fn(
9595
"fnt.generateId",
9696
{
@@ -101,14 +101,17 @@ describe("omittable input", () => {
101101
output: v.string(),
102102
},
103103
(c) => {
104-
expectTypeOf(c.input).toEqualTypeOf<{ size: number } | undefined>();
105-
if (c.input === undefined) return "missing";
104+
expectTypeOf(c.input).toEqualTypeOf<
105+
{ size: number } | undefined | null
106+
>();
107+
if (c.input == null) return "missing";
106108
expectTypeOf(c.input.size).toEqualTypeOf<number>();
107109
return `s${c.input.size}`;
108110
},
109111
);
110112
expect(generateId()).toBe("missing");
111113
expect(generateId(undefined)).toBe("missing");
114+
expect(generateId(null)).toBe("missing");
112115
// An empty object is present - field defaults apply.
113116
expect(generateId({})).toBe("s32");
114117
expect(generateId({ size: 8 })).toBe("s8");
@@ -528,7 +531,7 @@ describe("fn as input schema", () => {
528531
});
529532
const run = v.fn({ input: hooks }, (c) => {
530533
expectTypeOf(c.input.onCreate).toEqualTypeOf<
531-
((input: { id: string }) => any) | undefined
534+
((input: { id: string }) => any) | undefined | null
532535
>();
533536
expectTypeOf(c.input.onDelete).toEqualTypeOf<
534537
(input: { id: string }) => any
@@ -537,6 +540,7 @@ describe("fn as input schema", () => {
537540
});
538541
// Required sibling stays required - omit onCreate only.
539542
expect(run({ onDelete: () => null })).toBe("skipped");
543+
expect(run({ onDelete: () => null, onCreate: null })).toBe("skipped");
540544
expect(
541545
run({
542546
onDelete: () => null,

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

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ describe("v.string type-arg + optional DX", () => {
88
const field = v.string<"a" | "b">({ optional: true });
99
expectTypeOf<InferType<typeof field>>().toEqualTypeOf<"a" | "b">();
1010
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
11-
"a" | "b" | undefined
11+
"a" | "b" | undefined | null
1212
>();
1313
});
1414

@@ -40,42 +40,46 @@ describe("other vTypes type-arg + optional DX", () => {
4040
it("v.any with type param accepts optional: true", () => {
4141
const field = v.any<{ id: string }>({ optional: true });
4242
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
43-
{ id: string } | undefined
43+
{ id: string } | undefined | null
4444
>();
4545
});
4646

4747
it("v.number accepts optional: true without a type param", () => {
4848
const field = v.number({ optional: true });
4949
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
50-
number | undefined
50+
number | undefined | null
5151
>();
5252
});
5353

5454
it("v.number with output type param accepts optional: true", () => {
5555
const field = v.number<number>({ optional: true });
5656
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
57-
number | undefined
57+
number | undefined | null
5858
>();
5959
});
6060

6161
it("v.boolean/date accept optional: true", () => {
6262
const b = v.boolean({ optional: true });
6363
const d = v.date({ optional: true });
64-
expectTypeOf<InferOutput<typeof b>>().toEqualTypeOf<boolean | undefined>();
65-
expectTypeOf<InferOutput<typeof d>>().toEqualTypeOf<Date | undefined>();
64+
expectTypeOf<InferOutput<typeof b>>().toEqualTypeOf<
65+
boolean | undefined | null
66+
>();
67+
expectTypeOf<InferOutput<typeof d>>().toEqualTypeOf<
68+
Date | undefined | null
69+
>();
6670
});
6771

6872
it("v.array with element accepts optional: true", () => {
6973
const field = v.array(v.string<"a" | "b">(), { optional: true });
7074
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
71-
("a" | "b")[] | undefined
75+
("a" | "b")[] | undefined | null
7276
>();
7377
});
7478

7579
it("v.object with shape accepts optional: true", () => {
7680
const field = v.object({ kind: v.string<"a" | "b">() }, { optional: true });
7781
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
78-
{ kind: "a" | "b" } | undefined
82+
{ kind: "a" | "b" } | undefined | null
7983
>();
8084
});
8185

@@ -108,9 +112,9 @@ describe("other vTypes type-arg + optional DX", () => {
108112
{ optional: true, default: {} },
109113
);
110114
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<{
111-
password: { hash?: string; verify?: string };
115+
password: { hash?: string | null; verify?: string | null };
112116
minPasswordLength: number;
113-
maxPasswordLength?: number;
117+
maxPasswordLength?: number | null;
114118
}>();
115119
});
116120

@@ -251,8 +255,9 @@ describe("v.union", () => {
251255
it("optional and default behave like other types", () => {
252256
const optional = v.union([v.string(), v.number()], { optional: true });
253257
expect(validate(optional, undefined, "x")).toBeUndefined();
258+
expect(validate(optional, null, "x")).toBeNull();
254259
expectTypeOf<InferOutput<typeof optional>>().toEqualTypeOf<
255-
string | number | undefined
260+
string | number | undefined | null
256261
>();
257262

258263
const withDefault = v.union([v.string(), v.number()], { default: 0 });
@@ -262,6 +267,27 @@ describe("v.union", () => {
262267
>();
263268
});
264269

270+
it("optional accepts null the same way as undefined", () => {
271+
const field = v.string({ optional: true });
272+
expect(validate(field, null, "x")).toBeNull();
273+
expect(validate(field, undefined, "x")).toBeUndefined();
274+
expectTypeOf<InferArgs<typeof field>>().toEqualTypeOf<string | null>();
275+
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<
276+
string | undefined | null
277+
>();
278+
279+
const withDefault = v.string({ optional: true, default: "hi" });
280+
expect(validate(withDefault, null, "x")).toBe("hi");
281+
expect(validate(withDefault, undefined, "x")).toBe("hi");
282+
283+
const shaped = v.object({
284+
name: v.string({ optional: true }),
285+
});
286+
expect(validate(shaped, { name: null }, "x")).toEqual({ name: null });
287+
expect(validate(shaped, {}, "x")).toEqual({});
288+
expect(() => validate(v.string(), null, "x")).toThrow(/expected string/);
289+
});
290+
265291
it("aggregates issues from every failing branch", () => {
266292
const field = v.union([v.string({ min: 2 }), v.number({ min: 10 })]);
267293
try {

packages/experimental/src/schema.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface TypeDefination<T, O, D = never> extends Rules {
3838
* is called (fresh value per validate) except on `function` schemas,
3939
* where the default IS the fn. */
4040
default?: D | (() => D);
41-
/** When true, `undefined` passes straight through unvalidated. */
41+
/** When true, `undefined` and `null` pass straight through unvalidated. */
4242
optional?: boolean;
4343
transform?: (value: any) => O;
4444
/** Opaque plugin attributes - ignored by validate / Infer*. */
@@ -52,8 +52,9 @@ export type TypeOptions<T, O> = {
5252
};
5353

5454
/**
55-
* `optional` widens the output; `default` keeps it narrow because a value
56-
* is always produced. Declaring both means optional to send, never absent.
55+
* `optional` widens the output with `| undefined | null`; `default` keeps
56+
* it narrow because a value is always produced. Declaring both means
57+
* optional to send, never absent.
5758
*
5859
* Helpers select among these via option-shape overloads rather than `Opt` /
5960
* `D` type parameters: providing a partial type argument (e.g.
@@ -62,7 +63,7 @@ export type TypeOptions<T, O> = {
6263
*/
6364
type OutOf<O, D, Opt> = [Opt] extends [true]
6465
? [D] extends [never]
65-
? O | undefined
66+
? O | undefined | null
6667
: O
6768
: O;
6869

@@ -71,15 +72,15 @@ type OutOf<O, D, Opt> = [Opt] extends [true]
7172
* `TypeDefination<any, infer O, …>` drops `| undefined` because `output?`
7273
* is optional and TypeScript attributes the undefined to the property.
7374
* When the third type arg is `undefined` (optional, no default), put
74-
* `| undefined` back so handlers see the same absence validate produces
75-
* at runtime.
75+
* `| undefined | null` back so handlers see the same absence validate
76+
* produces at runtime.
7677
*/
7778
type OutputOf<F> =
7879
F extends TypeDefination<any, infer O, infer D>
7980
? [D] extends [never]
8081
? O
8182
: undefined extends D
82-
? O | undefined
83+
? O | undefined | null
8384
: O
8485
: never;
8586

@@ -256,20 +257,23 @@ type FieldIn<F> = F extends { $var: true; schema?: infer S }
256257
? InferArgs<NonNullable<S>>
257258
: F extends { $fnSchema: { input?: infer FI; output?: infer FO } }
258259
? SchemaFnIn<FI, FO> & FnVarBrand<FI>
259-
: F extends TypeDefination<infer T, any, any>
260-
? T
260+
: F extends TypeDefination<infer T, any, infer D>
261+
? undefined extends D
262+
? T | null
263+
: T
261264
: F extends Record<string, unknown>
262265
? ArgsShape<F>
263266
: F;
264267

265268
/**
266269
* `optional: true` on `v.fn.type` widens the same way a type's `optional`
267-
* does: absent without a default means the value may be `undefined`.
270+
* does: absent without a default means the value may be `undefined` or
271+
* `null`.
268272
*/
269273
type FnSchemaOut<F, Fn> = F extends { optional: true }
270274
? F extends { default: infer _D }
271275
? Fn
272-
: Fn | undefined
276+
: Fn | undefined | null
273277
: Fn;
274278

275279
/**
@@ -331,8 +335,10 @@ export type InferArgs<I> = I extends { $var: true; schema?: infer S }
331335
? SchemaFnIn<FI, FO> & FnVarBrand<FI>
332336
: I extends readonly unknown[]
333337
? { -readonly [K in keyof I]: InferArgs<I[K]> }
334-
: I extends TypeDefination<infer T, any, any>
335-
? T
338+
: I extends TypeDefination<infer T, any, infer D>
339+
? undefined extends D
340+
? T | null
341+
: T
336342
: ArgsShape<I>;
337343

338344
export const isType = (value: any): value is TypeDefination<any, any> =>
@@ -542,11 +548,12 @@ export const validate = (
542548
value: unknown,
543549
path: string,
544550
): any => {
545-
// `undefined` falls back to the declared default before anything else,
546-
// then to `optional`, which passes it through untouched. A shaped
547-
// object with no default treats omit as `{}` so all-optional fields
548-
// can be left off the call without a dummy payload.
549-
if (value === undefined) {
551+
// `undefined` / (when optional) `null` fall back to the declared
552+
// default before anything else, then to `optional`, which passes the
553+
// absence through untouched. A shaped object with no default treats
554+
// omit as `{}` so all-optional fields can be left off the call without
555+
// a dummy payload. Non-optional `null` falls through to the type check.
556+
if (value === undefined || (value === null && def.optional)) {
550557
if (def.default !== undefined) {
551558
// Factories produce a fresh value each time - needed for Date /
552559
// object / array defaults. Skip on `function` schemas: there the
@@ -555,7 +562,7 @@ export const validate = (
555562
typeof def.default === "function" && def.name !== "function"
556563
? (def.default as () => unknown)()
557564
: def.default;
558-
} else if (def.optional) return undefined;
565+
} else if (def.optional) return value;
559566
else if (def.name === "object" && def.shape !== undefined) value = {};
560567
}
561568
// A var used as an input field validates against its own schema. An
@@ -881,7 +888,7 @@ type ArrayFn = {
881888
(
882889
element?: undefined,
883890
options?: ArrayOptions<undefined, any[]> & { optional: true },
884-
): TypeDefination<any[], any[] | undefined, undefined>;
891+
): TypeDefination<any[], any[] | undefined | null, undefined>;
885892
(
886893
element?: undefined,
887894
options?: ArrayOptions<undefined, any[]> & {

0 commit comments

Comments
 (0)