Skip to content

Commit eb6700f

Browse files
committed
fix(experimental): keep bare null/literals in v.union inference
FieldOut/FieldIn fell through to never for non-schema arms, so v.union([user, null]) typed as User only. Preserve F, and map bare literals through asType so runtime validation matches.
1 parent 4ba169b commit eb6700f

2 files changed

Lines changed: 64 additions & 6 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,46 @@ describe("v.union", () => {
208208
expect(err.issues.length).toBeGreaterThanOrEqual(2);
209209
}
210210
});
211+
212+
it("preserves bare null (and other literals) in the inferred union", () => {
213+
const user = v.var("union_user", {
214+
schema: v.object({ id: v.string(), name: v.string() }),
215+
});
216+
const field = v.union([user, null]);
217+
expectTypeOf<InferOutput<typeof field>>().toEqualTypeOf<{
218+
id: string;
219+
name: string;
220+
} | null>();
221+
expectTypeOf<InferArgs<typeof field>>().toEqualTypeOf<{
222+
id: string;
223+
name: string;
224+
} | null>();
225+
expect(validate(field, null, "x")).toBeNull();
226+
expect(validate(field, { id: "1", name: "a" }, "x")).toEqual({
227+
id: "1",
228+
name: "a",
229+
});
230+
231+
const literals = v.union([null, "idle", 0] as const);
232+
expectTypeOf<InferOutput<typeof literals>>().toEqualTypeOf<
233+
null | "idle" | 0
234+
>();
235+
expect(validate(literals, null, "x")).toBeNull();
236+
expect(validate(literals, "idle", "x")).toBe("idle");
237+
expect(validate(literals, 0, "x")).toBe(0);
238+
expect(() => validate(literals, "busy", "x")).toThrow(/expected/);
239+
});
240+
241+
it("fn output: v.union([var, null]) accepts a handler that returns null", () => {
242+
const user = v.var("union_out_user", {
243+
schema: v.object({ id: v.string() }),
244+
});
245+
const find = v.fn(
246+
"union.find",
247+
{ output: v.union([user, null]) },
248+
(_c) => null,
249+
);
250+
expectTypeOf(find).returns.toEqualTypeOf<{ id: string } | null>();
251+
expect(find()).toBeNull();
252+
});
211253
});

packages/experimental/src/schema.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,16 @@ export const outputContract = (
229229
};
230230

231231
/**
232-
* One input field, in four flavours:
232+
* One input field, in five flavours:
233233
* - a `v.var()`, whose shape comes from the var's own `schema`
234234
* - a handler-less `v.fn(...)` builder, which types the field as a FN
235235
* - a type from `v.string()` / `v.object()` / ...
236236
* - a bare nested record, which recurses
237+
* - anything else (e.g. `null`, `"lit"`) kept as itself - so
238+
* `v.union([user, null])` infers `User | null`, not just `User`
237239
*
238-
* The record case has to come last: a TypeDefination is itself a record,
239-
* and so is a builder.
240+
* The record case has to come after TypeDefination: a TypeDefination is
241+
* itself a record, and so is a builder.
240242
*/
241243
type FieldOut<F> = F extends { $var: true; schema?: infer S }
242244
? InferInput<NonNullable<S>>
@@ -246,7 +248,7 @@ type FieldOut<F> = F extends { $var: true; schema?: infer S }
246248
? OutputOf<F>
247249
: F extends Record<string, unknown>
248250
? Prettify<{ [K in keyof F]: FieldOut<F[K]> }>
249-
: never;
251+
: F;
250252

251253
type FieldIn<F> = F extends { $var: true; schema?: infer S }
252254
? InferArgs<NonNullable<S>>
@@ -256,7 +258,7 @@ type FieldIn<F> = F extends { $var: true; schema?: infer S }
256258
? T
257259
: F extends Record<string, unknown>
258260
? ArgsShape<F>
259-
: never;
261+
: F;
260262

261263
/**
262264
* `optional: true` on `v.fn.type` widens the same way a type's `optional`
@@ -359,7 +361,21 @@ export const asType = (value: any): TypeDefination<any, any> =>
359361
} as TypeDefination<any, any>)
360362
: isType(value)
361363
? value
362-
: { name: "object", shape: value };
364+
: // Bare literals in `v.union([schema, null])` / `["a", "b"]`:
365+
// map to a type whose `name` matches `typeOf`, with `enum`
366+
// pinning the exact value for string/number/boolean.
367+
value === null
368+
? ({ name: "null" } as TypeDefination<null, null>)
369+
: value === undefined
370+
? ({ name: "undefined" } as TypeDefination<undefined, undefined>)
371+
: typeof value === "string" ||
372+
typeof value === "number" ||
373+
typeof value === "boolean"
374+
? ({
375+
name: typeof value,
376+
enum: [value],
377+
} as TypeDefination<any, any>)
378+
: { name: "object", shape: value };
363379

364380
/**
365381
* Replace the whole `$attrs` bag. On a var, rebinds `customize` so a later

0 commit comments

Comments
 (0)