From dfd1820eb6b953fe565bd67a9a22e4f357271cfa Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sat, 27 Sep 2025 21:43:33 +0200 Subject: [PATCH 01/24] Add valibot field schemas --- package-lock.json | 20 ++++++ package.json | 5 ++ src/valibot/index.ts | 10 +++ src/valibot/schemas.ts | 142 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/valibot/index.ts create mode 100644 src/valibot/schemas.ts diff --git a/package-lock.json b/package-lock.json index 37893f6..1454263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,17 @@ "@types/node": "^24.5.2", "prettier": "^3.6.2", "typescript": "^5.9.2", + "valibot": "^1.1.0", "vitest": "^3.2.4" }, "peerDependencies": { + "valibot": "^1.1.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "valibot": { + "optional": true + }, "zod": { "optional": true } @@ -2720,6 +2725,21 @@ "node": ">= 4.0.0" } }, + "node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", diff --git a/package.json b/package.json index 7d8357a..6e706b9 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,17 @@ "@types/node": "^24.5.2", "prettier": "^3.6.2", "typescript": "^5.9.2", + "valibot": "^1.1.0", "vitest": "^3.2.4" }, "peerDependencies": { + "valibot": "^1.1.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "valibot": { + "optional": true + }, "zod": { "optional": true } diff --git a/src/valibot/index.ts b/src/valibot/index.ts new file mode 100644 index 0000000..13ed950 --- /dev/null +++ b/src/valibot/index.ts @@ -0,0 +1,10 @@ +export { + string, + number, + boolean, + date, + bigint, + picklist, + file, + array, +} from "./schemas.js"; diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts new file mode 100644 index 0000000..71d3b2f --- /dev/null +++ b/src/valibot/schemas.ts @@ -0,0 +1,142 @@ +import * as v from "valibot"; + +export function string< + const TMessage extends v.ErrorMessage | undefined, +>(message?: TMessage) { + return v.pipe( + v.string(message), + v.transform((input) => { + if (input === "") { + return undefined; + } + return input; + }), + v.string(message), + ); +} + +export function number< + const TMessage extends + | v.ErrorMessage + | undefined, +>(message?: TMessage) { + return v.pipe( + v.string(message), + v.transform((input) => { + if (input.trim() === "") { + return undefined; + } + return Number(input); + }), + v.number(message), + ); +} + +export function bigint< + const TMessage extends + | v.ErrorMessage + | undefined, +>(message?: TMessage) { + return v.pipe( + v.string(message), + v.transform((input) => { + if (input.trim() === "") { + return undefined; + } + try { + return BigInt(input); + } catch { + return input; + } + }), + v.bigint(message), + ); +} + +export function boolean< + const TMessage extends + | v.ErrorMessage + | undefined, +>(message?: TMessage) { + return v.pipe( + v.string(message), + v.transform((input) => { + if (input === "") { + return undefined; + } + return input === "true" || + input === "on" || + input === "1" || + input === "yes" + ? true + : false; + }), + v.boolean(message), + ); +} + +export function date< + const TMessage extends + | v.ErrorMessage + | undefined, +>(message?: TMessage) { + return v.pipe( + v.string(message), + v.transform((input) => { + if (input === "") { + return undefined; + } + const date = new Date(input); + return Number.isNaN(date.getTime()) ? input : date; + }), + v.date(message), + ); +} + +export function picklist< + const TOptions extends v.PicklistOptions, + const TMessage extends + | v.ErrorMessage< + v.PicklistIssue | v.StringIssue | v.NumberIssue | v.BigintIssue + > + | undefined, +>(options: TOptions, message?: TMessage) { + return v.pipe( + v.union([string(message), number(message), bigint(message)]), + v.picklist(options, message), + ); +} + +export function file< + const TMessage extends v.ErrorMessage | undefined, +>(message?: TMessage) { + return v.pipe( + v.file(message), + v.transform((input) => { + if (input.size === 0) { + return undefined; + } + return input; + }), + v.file(message), + ); +} + +export function array( + item: v.BaseSchema>, + message?: v.ErrorMessage, +) { + return v.pipe( + v.unknown(), + v.transform((input) => { + if (Array.isArray(input)) { + return input; + } + if (input === "") { + return []; + } + return [input]; + }), + v.array(item, message), + ); +} From 483ad85827515ec1e852f986b1b1416b6746fd75 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sat, 27 Sep 2025 22:07:04 +0200 Subject: [PATCH 02/24] test valibot field schemas --- test/valibot/schemas.test.ts | 231 +++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test/valibot/schemas.test.ts diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts new file mode 100644 index 0000000..ff3ba74 --- /dev/null +++ b/test/valibot/schemas.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from "vitest"; +import * as vf from "../../src/valibot/schemas.js"; +import * as v from "valibot"; + +describe("valibot schemas preprocessing", () => { + describe("string", () => { + it("should return undefined for empty strings", () => { + const schema = vf.string(); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should return non-empty strings as-is", () => { + const schema = vf.string(); + const result = v.safeParse(schema, "hello"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe("hello"); + } + }); + }); + + describe("number", () => { + it("should return undefined for empty strings", () => { + const schema = vf.number(); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should return undefined for whitespace-only strings", () => { + const schema = vf.number(); + const result = v.safeParse(schema, " "); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should convert string numbers to numbers", () => { + const schema = vf.number(); + const result = v.safeParse(schema, "123"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(123); + } + }); + + it("should handle invalid number strings", () => { + const schema = vf.number(); + const result = v.safeParse(schema, "abc"); + expect(result.success).toBe(false); + expect(result.output).toBeNaN(); + }); + }); + + describe("boolean", () => { + it("should return undefined for empty strings", () => { + const schema = vf.boolean(); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should return true for truthy string values", () => { + const schema = vf.boolean(); + const truthyValues = ["true", "on", "1", "yes"]; + + truthyValues.forEach((value) => { + const result = v.safeParse(schema, value); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(true); + } + }); + }); + + it("should return false for falsy string values", () => { + const schema = vf.boolean(); + const falsyValues = ["false", "off", "0", "no", "maybe", "hello"]; + + falsyValues.forEach((value) => { + const result = v.safeParse(schema, value); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(false); + } + }); + }); + }); + + describe("date", () => { + it("should return undefined for empty strings", () => { + const schema = vf.date(); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should convert string dates to Date objects", () => { + const schema = vf.date(); + const result = v.safeParse(schema, "2023-01-01"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeInstanceOf(Date); + expect(result.output.getFullYear()).toBe(2023); + } + }); + + it("should handle invalid date strings", () => { + const schema = vf.date(); + const result = v.safeParse(schema, "not-a-date"); + expect(result.success).toBe(false); + expect(result.output).toBe("not-a-date"); + }); + }); + + describe("file", () => { + it("should return undefined for empty files", () => { + const schema = vf.file(); + const emptyFile = new File([], "empty.txt"); + const result = v.safeParse(schema, emptyFile); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should return valid files as-is", () => { + const schema = vf.file(); + const file = new File(["content"], "test.txt"); + const result = v.safeParse(schema, file); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(file); + } + }); + }); + + describe("picklist", () => { + it("should return undefined for empty strings", () => { + const schema = vf.picklist(["a", "b", "c"]); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + expect(result.output).toBe(""); + }); + + it("should return valid picklist values as-is", () => { + const schema = vf.picklist(["a", "b", "c"]); + const result = v.safeParse(schema, "a"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe("a"); + } + }); + + it("should handle invalid picklist values", () => { + const schema = vf.picklist(["a", "b", "c"]); + const result = v.safeParse(schema, "d"); + expect(result.success).toBe(false); + expect(result.output).toBe("d"); + }); + }); + + describe("bigint", () => { + it("should return undefined for empty strings", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should return undefined for whitespace-only strings", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, " "); + expect(result.success).toBe(false); + expect(result.output).toBeUndefined(); + }); + + it("should convert string numbers to bigint", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, "123"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(123n); + } + }); + + it("should handle invalid bigint strings", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, "abc"); + expect(result.success).toBe(false); + expect(result.output).toBe("abc"); + }); + }); + + describe("array", () => { + it("should return empty array for empty strings", () => { + const schema = vf.array(vf.string()); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual([]); + } + }); + + it("should pass through arrays unchanged", () => { + const schema = vf.array(vf.string()); + const result = v.safeParse(schema, ["a", "b", "c"]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual(["a", "b", "c"]); + } + }); + + it("should convert single values to single-item arrays", () => { + const schema = vf.array(vf.string()); + const result = v.safeParse(schema, "hello"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual(["hello"]); + } + }); + + it("should validate array elements", () => { + const schema = vf.array(vf.number()); + const result = v.safeParse(schema, ["123", "456"]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual([123, 456]); + } + }); + }); +}); From 2b85b51270effe9259afd67f488c5687549772f3 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sat, 27 Sep 2025 22:33:40 +0200 Subject: [PATCH 03/24] update picklist --- src/valibot/schemas.ts | 37 ++++++++++++++++++++++++++++++++++-- test/valibot/schemas.test.ts | 34 ++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts index 71d3b2f..9be9717 100644 --- a/src/valibot/schemas.ts +++ b/src/valibot/schemas.ts @@ -94,7 +94,10 @@ export function date< } export function picklist< - const TOptions extends v.PicklistOptions, + const TOptions extends + | readonly string[] + | readonly number[] + | readonly bigint[], const TMessage extends | v.ErrorMessage< v.PicklistIssue | v.StringIssue | v.NumberIssue | v.BigintIssue @@ -102,7 +105,37 @@ export function picklist< | undefined, >(options: TOptions, message?: TMessage) { return v.pipe( - v.union([string(message), number(message), bigint(message)]), + v.string(), + v.transform((input) => { + const isString = options.some((opt) => typeof opt === "string"); + const isNumber = options.some((opt) => typeof opt === "number"); + const isBigInt = options.some((opt) => typeof opt === "bigint"); + + if (isString) { + if (input === "") { + return undefined; + } + return input; + } + if (isNumber) { + if (input.trim() === "") { + return undefined; + } + return Number(input); + } + + if (isBigInt) { + if (input.trim() === "") { + return undefined; + } + try { + return BigInt(input); + } catch { + return input; + } + } + return input; + }), v.picklist(options, message), ); } diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts index ff3ba74..bb71b29 100644 --- a/test/valibot/schemas.test.ts +++ b/test/valibot/schemas.test.ts @@ -139,7 +139,7 @@ describe("valibot schemas preprocessing", () => { const schema = vf.picklist(["a", "b", "c"]); const result = v.safeParse(schema, ""); expect(result.success).toBe(false); - expect(result.output).toBe(""); + expect(result.output).toBeUndefined(); }); it("should return valid picklist values as-is", () => { @@ -157,6 +157,38 @@ describe("valibot schemas preprocessing", () => { expect(result.success).toBe(false); expect(result.output).toBe("d"); }); + + it("should work with number picklist values", () => { + const schema = vf.picklist([1, 2, 3]); + const result = v.safeParse(schema, "2"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(2); + } + }); + + it("should handle invalid number picklist values", () => { + const schema = vf.picklist([1, 2, 3]); + const result = v.safeParse(schema, "4"); + expect(result.success).toBe(false); + expect(result.output).toBe(4); + }); + + it("should work with bigint picklist values", () => { + const schema = vf.picklist([1n, 2n, 3n]); + const result = v.safeParse(schema, "2"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(2n); + } + }); + + it("should handle invalid bigint picklist values", () => { + const schema = vf.picklist([1n, 2n, 3n]); + const result = v.safeParse(schema, "4"); + expect(result.success).toBe(false); + expect(result.output).toBe(4n); + }); }); describe("bigint", () => { From e53e349e894dfa4a0f7faf4d679f4672aaafe6cd Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sat, 27 Sep 2025 22:51:28 +0200 Subject: [PATCH 04/24] simplify picklist --- src/valibot/schemas.ts | 39 +++++------------------------------- test/valibot/schemas.test.ts | 32 ----------------------------- 2 files changed, 5 insertions(+), 66 deletions(-) diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts index 9be9717..2e6a1bd 100644 --- a/src/valibot/schemas.ts +++ b/src/valibot/schemas.ts @@ -94,45 +94,16 @@ export function date< } export function picklist< - const TOptions extends - | readonly string[] - | readonly number[] - | readonly bigint[], + const TOptions extends string[] | Readonly, const TMessage extends - | v.ErrorMessage< - v.PicklistIssue | v.StringIssue | v.NumberIssue | v.BigintIssue - > + | v.ErrorMessage | undefined, >(options: TOptions, message?: TMessage) { return v.pipe( - v.string(), + v.string(message), v.transform((input) => { - const isString = options.some((opt) => typeof opt === "string"); - const isNumber = options.some((opt) => typeof opt === "number"); - const isBigInt = options.some((opt) => typeof opt === "bigint"); - - if (isString) { - if (input === "") { - return undefined; - } - return input; - } - if (isNumber) { - if (input.trim() === "") { - return undefined; - } - return Number(input); - } - - if (isBigInt) { - if (input.trim() === "") { - return undefined; - } - try { - return BigInt(input); - } catch { - return input; - } + if (input === "") { + return undefined; } return input; }), diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts index bb71b29..48644ab 100644 --- a/test/valibot/schemas.test.ts +++ b/test/valibot/schemas.test.ts @@ -157,38 +157,6 @@ describe("valibot schemas preprocessing", () => { expect(result.success).toBe(false); expect(result.output).toBe("d"); }); - - it("should work with number picklist values", () => { - const schema = vf.picklist([1, 2, 3]); - const result = v.safeParse(schema, "2"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(2); - } - }); - - it("should handle invalid number picklist values", () => { - const schema = vf.picklist([1, 2, 3]); - const result = v.safeParse(schema, "4"); - expect(result.success).toBe(false); - expect(result.output).toBe(4); - }); - - it("should work with bigint picklist values", () => { - const schema = vf.picklist([1n, 2n, 3n]); - const result = v.safeParse(schema, "2"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(2n); - } - }); - - it("should handle invalid bigint picklist values", () => { - const schema = vf.picklist([1n, 2n, 3n]); - const result = v.safeParse(schema, "4"); - expect(result.success).toBe(false); - expect(result.output).toBe(4n); - }); }); describe("bigint", () => { From 872ac13a396cc669ac33916544d0b5639cd821f1 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 10:50:02 +0200 Subject: [PATCH 05/24] Pass through non-string values unchanged --- src/valibot/schemas.ts | 56 ++++++++++++++++++++-------------- test/valibot/schemas.test.ts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts index 2e6a1bd..1e7a8db 100644 --- a/src/valibot/schemas.ts +++ b/src/valibot/schemas.ts @@ -4,8 +4,12 @@ export function string< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { return v.pipe( - v.string(message), + v.unknown(), v.transform((input) => { + if (typeof input !== "string") { + return input; + } + if (input === "") { return undefined; } @@ -16,13 +20,14 @@ export function string< } export function number< - const TMessage extends - | v.ErrorMessage - | undefined, + const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { return v.pipe( - v.string(message), + v.unknown(), v.transform((input) => { + if (typeof input !== "string") { + return input; + } if (input.trim() === "") { return undefined; } @@ -33,13 +38,14 @@ export function number< } export function bigint< - const TMessage extends - | v.ErrorMessage - | undefined, + const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { return v.pipe( - v.string(message), + v.unknown(), v.transform((input) => { + if (typeof input !== "string") { + return input; + } if (input.trim() === "") { return undefined; } @@ -54,13 +60,14 @@ export function bigint< } export function boolean< - const TMessage extends - | v.ErrorMessage - | undefined, + const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { return v.pipe( - v.string(message), + v.unknown(), v.transform((input) => { + if (typeof input !== "string") { + return input; + } if (input === "") { return undefined; } @@ -76,13 +83,14 @@ export function boolean< } export function date< - const TMessage extends - | v.ErrorMessage - | undefined, + const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { return v.pipe( - v.string(message), + v.unknown(), v.transform((input) => { + if (typeof input !== "string") { + return input; + } if (input === "") { return undefined; } @@ -95,13 +103,14 @@ export function date< export function picklist< const TOptions extends string[] | Readonly, - const TMessage extends - | v.ErrorMessage - | undefined, + const TMessage extends v.ErrorMessage | undefined, >(options: TOptions, message?: TMessage) { return v.pipe( - v.string(message), + v.unknown(), v.transform((input) => { + if (typeof input !== "string") { + return input; + } if (input === "") { return undefined; } @@ -115,8 +124,11 @@ export function file< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { return v.pipe( - v.file(message), + v.unknown(), v.transform((input) => { + if (!(input instanceof File)) { + return input; + } if (input.size === 0) { return undefined; } diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts index 48644ab..a5d149e 100644 --- a/test/valibot/schemas.test.ts +++ b/test/valibot/schemas.test.ts @@ -11,6 +11,13 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-string values unchanged", () => { + const schema = vf.string(); + const result = v.safeParse(schema, 123); + expect(result.success).toBe(false); + expect(result.output).toBe(123); + }); + it("should return non-empty strings as-is", () => { const schema = vf.string(); const result = v.safeParse(schema, "hello"); @@ -36,6 +43,15 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-string values unchanged", () => { + const schema = vf.number(); + const result = v.safeParse(schema, 42); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(42); + } + }); + it("should convert string numbers to numbers", () => { const schema = vf.number(); const result = v.safeParse(schema, "123"); @@ -61,6 +77,15 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-string values unchanged", () => { + const schema = vf.boolean(); + const result = v.safeParse(schema, true); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(true); + } + }); + it("should return true for truthy string values", () => { const schema = vf.boolean(); const truthyValues = ["true", "on", "1", "yes"]; @@ -96,6 +121,16 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-string values unchanged", () => { + const schema = vf.date(); + const date = new Date(); + const result = v.safeParse(schema, date); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(date); + } + }); + it("should convert string dates to Date objects", () => { const schema = vf.date(); const result = v.safeParse(schema, "2023-01-01"); @@ -123,6 +158,13 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-File values unchanged", () => { + const schema = vf.file(); + const result = v.safeParse(schema, "not-a-file"); + expect(result.success).toBe(false); + expect(result.output).toBe("not-a-file"); + }); + it("should return valid files as-is", () => { const schema = vf.file(); const file = new File(["content"], "test.txt"); @@ -142,6 +184,13 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-string values unchanged", () => { + const schema = vf.picklist(["a", "b", "c"]); + const result = v.safeParse(schema, 123); + expect(result.success).toBe(false); + expect(result.output).toBe(123); + }); + it("should return valid picklist values as-is", () => { const schema = vf.picklist(["a", "b", "c"]); const result = v.safeParse(schema, "a"); @@ -174,6 +223,15 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBeUndefined(); }); + it("should pass through non-string values unchanged", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, 42n); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(42n); + } + }); + it("should convert string numbers to bigint", () => { const schema = vf.bigint(); const result = v.safeParse(schema, "123"); From f758cfb1d0b3db6dc94d19bd5d0520aee4bbcf6d Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:04:32 +0200 Subject: [PATCH 06/24] explicit falsy handling --- src/valibot/schemas.ts | 17 ++++++++++++++--- test/valibot/schemas.test.ts | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts index 1e7a8db..2f0121d 100644 --- a/src/valibot/schemas.ts +++ b/src/valibot/schemas.ts @@ -71,12 +71,23 @@ export function boolean< if (input === "") { return undefined; } - return input === "true" || + if ( + input === "true" || input === "on" || input === "1" || input === "yes" - ? true - : false; + ) { + return true; + } + if ( + input === "false" || + input === "off" || + input === "0" || + input === "no" + ) { + return false; + } + return input; }), v.boolean(message), ); diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts index a5d149e..b61eb0c 100644 --- a/test/valibot/schemas.test.ts +++ b/test/valibot/schemas.test.ts @@ -99,9 +99,9 @@ describe("valibot schemas preprocessing", () => { }); }); - it("should return false for falsy string values", () => { + it("should return false for explicit falsy string values", () => { const schema = vf.boolean(); - const falsyValues = ["false", "off", "0", "no", "maybe", "hello"]; + const falsyValues = ["false", "off", "0", "no"]; falsyValues.forEach((value) => { const result = v.safeParse(schema, value); @@ -111,6 +111,17 @@ describe("valibot schemas preprocessing", () => { } }); }); + + it("should fail validation for ambiguous string values", () => { + const schema = vf.boolean(); + const ambiguousValues = ["maybe", "hello", "banana", "sometimes"]; + + ambiguousValues.forEach((value) => { + const result = v.safeParse(schema, value); + expect(result.success).toBe(false); + expect(result.output).toBe(value); + }); + }); }); describe("date", () => { From f95fc48e9b379ed6d7e9c3806f6c017a460c956f Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:32:25 +0200 Subject: [PATCH 07/24] Align serialize false behavior --- src/serialize.ts | 10 ++++++++-- test/serialize.test.ts | 39 +++++++++++++++++++++++++++------------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/serialize.ts b/src/serialize.ts index 2eb2469..5c96b64 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -6,6 +6,11 @@ export interface SerializeOptions { * @default "on" */ booleanTrueValue?: "true" | "on" | "1" | "yes"; + + /** The string value to use for boolean `false`. + * @default "off" + */ + booleanFalseValue?: "false" | "off" | "0" | "no"; } /** @@ -21,6 +26,7 @@ export interface SerializeOptions { * ```ts * serialize(123); // Returns "123" * serialize(true); // Returns "on" + * serialize(false); // Returns "off" * serialize(new Date()) // Returns "2025-01-17T17:04:25.059Z" * serialize({username: "test", age: 100}) // Returns {username: "test", age: "100"} * ``` @@ -29,7 +35,7 @@ export function serialize( value: T, options: SerializeOptions = {}, ): InputValue { - const { booleanTrueValue = "on" } = options; + const { booleanTrueValue = "on", booleanFalseValue = "off" } = options; if (Array.isArray(value)) { return value.map((item) => serialize(item, options)) as InputValue; @@ -50,7 +56,7 @@ export function serialize( } if (typeof value === "boolean") { - return (value ? booleanTrueValue : "") as InputValue; + return (value ? booleanTrueValue : booleanFalseValue) as InputValue; } if (value instanceof Date) { diff --git a/test/serialize.test.ts b/test/serialize.test.ts index a188aba..8857c75 100644 --- a/test/serialize.test.ts +++ b/test/serialize.test.ts @@ -10,12 +10,15 @@ describe("serialize", () => { it("serializes booleans to strings", () => { expect(serialize(true)).toBe("on"); - expect(serialize(false)).toBe(""); + expect(serialize(false)).toBe("off"); }); it("serializes booleans with custom true value", () => { expect(serialize(true, { booleanTrueValue: "yes" })).toBe("yes"); - expect(serialize(false, { booleanTrueValue: "yes" })).toBe(""); + }); + + it("serializes booleans with custom false value", () => { + expect(serialize(false, { booleanFalseValue: "no" })).toBe("no"); }); it("serializes dates to ISO strings", () => { @@ -28,39 +31,51 @@ describe("serialize", () => { a: 1, b: "hello", c: true, - d: new Date("2024-02-20T10:00:00.000Z"), + d: false, + e: new Date("2024-02-20T10:00:00.000Z"), }; expect(serialize(obj)).toEqual({ a: "1", b: "hello", c: "on", - d: "2024-02-20T10:00:00.000Z", + d: "off", + e: "2024-02-20T10:00:00.000Z", }); }); it("serializes nested objects", () => { - const obj = { a: { b: 1, c: { d: "test" } } }; - expect(serialize(obj)).toEqual({ a: { b: "1", c: { d: "test" } } }); + const obj = { a: { b: 1, c: { d: "test", e: true, f: false } } }; + expect(serialize(obj)).toEqual({ + a: { + b: "1", + c: { + d: "test", + e: "on", + f: "off", + }, + }, + }); }); it("serializes arrays", () => { - const arr = [1, "test", false, new Date("2024-03-15T15:30:00.000Z")]; + const arr = [1, "test", true, false, new Date("2024-03-15T15:30:00.000Z")]; expect(serialize(arr)).toEqual([ "1", "test", - "", + "on", + "off", "2024-03-15T15:30:00.000Z", ]); }); it("serializes nested arrays and objects", () => { const data = { - a: [1, { b: true, c: new Date("2024-10-10") }], - d: { e: [2, 3] }, + a: [1, { b: true, c: false, d: new Date("2024-10-10") }], + e: { f: [2, 3], g: true, h: false }, }; expect(serialize(data)).toEqual({ - a: ["1", { b: "on", c: "2024-10-10T00:00:00.000Z" }], - d: { e: ["2", "3"] }, + a: ["1", { b: "on", c: "off", d: "2024-10-10T00:00:00.000Z" }], + e: { f: ["2", "3"], g: "on", h: "off" }, }); }); From 7eb472b224f8592a578ec1f796319654ba6d4dc5 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:39:30 +0200 Subject: [PATCH 08/24] extract coerce callbacks --- src/coerce.ts | 91 +++++++++++++++++++++++++++ src/valibot/schemas.ts | 140 ++++++----------------------------------- 2 files changed, 109 insertions(+), 122 deletions(-) create mode 100644 src/coerce.ts diff --git a/src/coerce.ts b/src/coerce.ts new file mode 100644 index 0000000..d43cc21 --- /dev/null +++ b/src/coerce.ts @@ -0,0 +1,91 @@ +export function coerceString(input: unknown): unknown { + if (typeof input !== "string") { + return input; + } + + if (input === "") { + return undefined; + } + return input; +} + +export function coerceNumber(input: unknown): unknown { + if (typeof input !== "string") { + return input; + } + if (input.trim() === "") { + return undefined; + } + return Number(input); +} + +export function coerceBigint(input: unknown): unknown { + if (typeof input !== "string") { + return input; + } + if (input.trim() === "") { + return undefined; + } + try { + return BigInt(input); + } catch { + return input; + } +} + +export function coerceBoolean(input: unknown): unknown { + if (typeof input !== "string") { + return input; + } + if (input === "") { + return undefined; + } + if (input === "true" || input === "on" || input === "1" || input === "yes") { + return true; + } + if (input === "false" || input === "off" || input === "0" || input === "no") { + return false; + } + return input; +} + +export function coerceDate(input: unknown): unknown { + if (typeof input !== "string") { + return input; + } + if (input === "") { + return undefined; + } + const date = new Date(input); + return Number.isNaN(date.getTime()) ? input : date; +} + +export function coercePicklist(input: unknown): unknown { + if (typeof input !== "string") { + return input; + } + if (input === "") { + return undefined; + } + return input; +} + +export function coerceFile(input: unknown): unknown { + if (!(input instanceof File)) { + return input; + } + if (input.size === 0) { + return undefined; + } + return input; +} + +export function coerceArray(input: unknown): unknown[] { + if (Array.isArray(input)) { + return input; + } + if (input === "") { + return []; + } + return [input]; +} diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts index 2f0121d..a8eacb6 100644 --- a/src/valibot/schemas.ts +++ b/src/valibot/schemas.ts @@ -1,115 +1,43 @@ import * as v from "valibot"; +import { + coerceString, + coerceNumber, + coerceBigint, + coerceBoolean, + coerceDate, + coercePicklist, + coerceFile, + coerceArray, +} from "../coerce.js"; export function string< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (typeof input !== "string") { - return input; - } - - if (input === "") { - return undefined; - } - return input; - }), - v.string(message), - ); + return v.pipe(v.unknown(), v.transform(coerceString), v.string(message)); } export function number< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (typeof input !== "string") { - return input; - } - if (input.trim() === "") { - return undefined; - } - return Number(input); - }), - v.number(message), - ); + return v.pipe(v.unknown(), v.transform(coerceNumber), v.number(message)); } export function bigint< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (typeof input !== "string") { - return input; - } - if (input.trim() === "") { - return undefined; - } - try { - return BigInt(input); - } catch { - return input; - } - }), - v.bigint(message), - ); + return v.pipe(v.unknown(), v.transform(coerceBigint), v.bigint(message)); } export function boolean< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (typeof input !== "string") { - return input; - } - if (input === "") { - return undefined; - } - if ( - input === "true" || - input === "on" || - input === "1" || - input === "yes" - ) { - return true; - } - if ( - input === "false" || - input === "off" || - input === "0" || - input === "no" - ) { - return false; - } - return input; - }), - v.boolean(message), - ); + return v.pipe(v.unknown(), v.transform(coerceBoolean), v.boolean(message)); } export function date< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (typeof input !== "string") { - return input; - } - if (input === "") { - return undefined; - } - const date = new Date(input); - return Number.isNaN(date.getTime()) ? input : date; - }), - v.date(message), - ); + return v.pipe(v.unknown(), v.transform(coerceDate), v.date(message)); } export function picklist< @@ -118,15 +46,7 @@ export function picklist< >(options: TOptions, message?: TMessage) { return v.pipe( v.unknown(), - v.transform((input) => { - if (typeof input !== "string") { - return input; - } - if (input === "") { - return undefined; - } - return input; - }), + v.transform(coercePicklist), v.picklist(options, message), ); } @@ -134,36 +54,12 @@ export function picklist< export function file< const TMessage extends v.ErrorMessage | undefined, >(message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (!(input instanceof File)) { - return input; - } - if (input.size === 0) { - return undefined; - } - return input; - }), - v.file(message), - ); + return v.pipe(v.unknown(), v.transform(coerceFile), v.file(message)); } export function array( item: v.BaseSchema>, message?: v.ErrorMessage, ) { - return v.pipe( - v.unknown(), - v.transform((input) => { - if (Array.isArray(input)) { - return input; - } - if (input === "") { - return []; - } - return [input]; - }), - v.array(item, message), - ); + return v.pipe(v.unknown(), v.transform(coerceArray), v.array(item, message)); } From 940dcc3223e8eb8cdd8461ccaf824191c45a1f09 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:43:26 +0200 Subject: [PATCH 09/24] Refactor zod schemas --- src/zod/schemas.ts | 121 ++++++++------------------------------------- 1 file changed, 20 insertions(+), 101 deletions(-) diff --git a/src/zod/schemas.ts b/src/zod/schemas.ts index 53e2c15..5f64b33 100644 --- a/src/zod/schemas.ts +++ b/src/zod/schemas.ts @@ -1,122 +1,52 @@ import * as z from "zod"; +import { + coerceString, + coerceNumber, + coerceBigint, + coerceBoolean, + coerceDate, + coercePicklist, + coerceFile, + coerceArray, +} from "../coerce.js"; export function string(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - return v; - }, z.string(params)); + return z.preprocess(coerceString, z.string(params)); } export function number(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v.trim() === "") { - return undefined; - } - return Number(v); - }, z.number(params)); + return z.preprocess(coerceNumber, z.number(params)); } export function bigint(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v.trim() === "") { - return undefined; - } - try { - return BigInt(v); - } catch { - return v; - } - }, z.bigint(params)); + return z.preprocess(coerceBigint, z.bigint(params)); } export function boolean(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - return v === "true" || v === "on" || v === "1" || v === "yes" ? true : v; - }, z.boolean(params)); + return z.preprocess(coerceBoolean, z.boolean(params)); } export function date(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - const date = new Date(v); - return Number.isNaN(date.getTime()) ? v : date; - }, z.date(params)); + return z.preprocess(coerceDate, z.date(params)); } export function enum_( values: T, params?: Parameters[1], ) { - return z.preprocess( - (v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - return v; - }, - z.enum(values, params), - ); + return z.preprocess(coercePicklist, z.enum(values, params)); } export function file(params?: Parameters[0]) { - return z.preprocess((v) => { - if (!(v instanceof File)) { - return v; - } - if (v.size === 0) { - return undefined; - } - return v; - }, z.file(params)); + return z.preprocess(coerceFile, z.file(params)); } export function email(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - return v; - }, z.email(params)); + return z.preprocess(coerceString, z.email(params)); } export function url(params?: Parameters[0]) { - return z.preprocess((v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - return v; - }, z.url(params)); + return z.preprocess(coerceString, z.url(params)); } export const object = z.object; @@ -125,16 +55,5 @@ export function array( element: T, params?: Parameters[1], ) { - return z.preprocess( - (v) => { - if (Array.isArray(v)) { - return v; - } - if (v === "") { - return []; - } - return [v]; - }, - z.array(element, params), - ); + return z.preprocess(coerceArray, z.array(element, params)); } From a52a5c91a759ed5b846b3da70da0399cbea30231 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:44:50 +0200 Subject: [PATCH 10/24] Remove coercePicklist --- src/coerce.ts | 10 ---------- src/valibot/schemas.ts | 3 +-- src/zod/schemas.ts | 3 +-- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/coerce.ts b/src/coerce.ts index d43cc21..163ced8 100644 --- a/src/coerce.ts +++ b/src/coerce.ts @@ -60,16 +60,6 @@ export function coerceDate(input: unknown): unknown { return Number.isNaN(date.getTime()) ? input : date; } -export function coercePicklist(input: unknown): unknown { - if (typeof input !== "string") { - return input; - } - if (input === "") { - return undefined; - } - return input; -} - export function coerceFile(input: unknown): unknown { if (!(input instanceof File)) { return input; diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts index a8eacb6..ab9fd1d 100644 --- a/src/valibot/schemas.ts +++ b/src/valibot/schemas.ts @@ -5,7 +5,6 @@ import { coerceBigint, coerceBoolean, coerceDate, - coercePicklist, coerceFile, coerceArray, } from "../coerce.js"; @@ -46,7 +45,7 @@ export function picklist< >(options: TOptions, message?: TMessage) { return v.pipe( v.unknown(), - v.transform(coercePicklist), + v.transform(coerceString), v.picklist(options, message), ); } diff --git a/src/zod/schemas.ts b/src/zod/schemas.ts index 5f64b33..c3070bf 100644 --- a/src/zod/schemas.ts +++ b/src/zod/schemas.ts @@ -5,7 +5,6 @@ import { coerceBigint, coerceBoolean, coerceDate, - coercePicklist, coerceFile, coerceArray, } from "../coerce.js"; @@ -34,7 +33,7 @@ export function enum_( values: T, params?: Parameters[1], ) { - return z.preprocess(coercePicklist, z.enum(values, params)); + return z.preprocess(coerceString, z.enum(values, params)); } export function file(params?: Parameters[0]) { From 138a10221c288f82917e1910c809581179e8f2d2 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:56:37 +0200 Subject: [PATCH 11/24] Add coerce tests --- test/coerce.test.ts | 168 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 test/coerce.test.ts diff --git a/test/coerce.test.ts b/test/coerce.test.ts new file mode 100644 index 0000000..e8066fe --- /dev/null +++ b/test/coerce.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { + coerceString, + coerceNumber, + coerceBigint, + coerceBoolean, + coerceDate, + coerceFile, + coerceArray, +} from "../src/coerce.js"; + +describe("coerce functions", () => { + describe("coerceString", () => { + it("should return undefined for empty strings", () => { + expect(coerceString("")).toBeUndefined(); + }); + + it("should pass through non-string values unchanged", () => { + expect(coerceString(123)).toBe(123); + expect(coerceString(null)).toBe(null); + expect(coerceString(undefined)).toBe(undefined); + }); + + it("should return non-empty strings as-is", () => { + expect(coerceString("hello")).toBe("hello"); + expect(coerceString(" ")).toBe(" "); + }); + }); + + describe("coerceNumber", () => { + it("should return undefined for empty strings", () => { + expect(coerceNumber("")).toBeUndefined(); + }); + + it("should return undefined for whitespace-only strings", () => { + expect(coerceNumber(" ")).toBeUndefined(); + }); + + it("should pass through non-string values unchanged", () => { + expect(coerceNumber(42)).toBe(42); + expect(coerceNumber(null)).toBe(null); + }); + + it("should convert valid number strings", () => { + expect(coerceNumber("42")).toBe(42); + expect(coerceNumber("3.14")).toBe(3.14); + expect(coerceNumber("-10")).toBe(-10); + }); + + it("should return NaN for invalid number strings", () => { + expect(coerceNumber("abc")).toBeNaN(); + expect(coerceNumber("12.34.56")).toBeNaN(); + }); + }); + + describe("coerceBigint", () => { + it("should return undefined for empty strings", () => { + expect(coerceBigint("")).toBeUndefined(); + }); + + it("should return undefined for whitespace-only strings", () => { + expect(coerceBigint(" ")).toBeUndefined(); + }); + + it("should pass through non-string values unchanged", () => { + expect(coerceBigint(42n)).toBe(42n); + expect(coerceBigint(null)).toBe(null); + }); + + it("should convert valid bigint strings", () => { + expect(coerceBigint("42")).toBe(42n); + expect(coerceBigint("9007199254740991")).toBe(9007199254740991n); + }); + + it("should return original string for invalid bigint strings", () => { + expect(coerceBigint("abc")).toBe("abc"); + expect(coerceBigint("12.34")).toBe("12.34"); + }); + }); + + describe("coerceBoolean", () => { + it("should return undefined for empty strings", () => { + expect(coerceBoolean("")).toBeUndefined(); + }); + + it("should pass through non-string values unchanged", () => { + expect(coerceBoolean(true)).toBe(true); + expect(coerceBoolean(false)).toBe(false); + expect(coerceBoolean(null)).toBe(null); + }); + + it("should convert truthy strings to true", () => { + expect(coerceBoolean("true")).toBe(true); + expect(coerceBoolean("on")).toBe(true); + expect(coerceBoolean("1")).toBe(true); + expect(coerceBoolean("yes")).toBe(true); + }); + + it("should convert falsy strings to false", () => { + expect(coerceBoolean("false")).toBe(false); + expect(coerceBoolean("off")).toBe(false); + expect(coerceBoolean("0")).toBe(false); + expect(coerceBoolean("no")).toBe(false); + }); + + it("should return original string for unrecognized values", () => { + expect(coerceBoolean("maybe")).toBe("maybe"); + expect(coerceBoolean("unknown")).toBe("unknown"); + }); + }); + + describe("coerceDate", () => { + it("should return undefined for empty strings", () => { + expect(coerceDate("")).toBeUndefined(); + }); + + it("should pass through non-string values unchanged", () => { + const date = new Date(); + expect(coerceDate(date)).toBe(date); + expect(coerceDate(null)).toBe(null); + }); + + it("should convert valid date strings", () => { + const result = coerceDate("2023-01-01"); + expect(result).toBeInstanceOf(Date); + expect((result as Date).getFullYear()).toBe(2023); + }); + + it("should return original string for invalid date strings", () => { + expect(coerceDate("invalid-date")).toBe("invalid-date"); + expect(coerceDate("32/13/2023")).toBe("32/13/2023"); + }); + }); + + describe("coerceFile", () => { + it("should return undefined for empty files", () => { + const emptyFile = new File([], "test.txt"); + expect(coerceFile(emptyFile)).toBeUndefined(); + }); + + it("should pass through non-File values unchanged", () => { + expect(coerceFile("not-a-file")).toBe("not-a-file"); + expect(coerceFile(null)).toBe(null); + }); + + it("should return non-empty files as-is", () => { + const file = new File(["content"], "test.txt"); + expect(coerceFile(file)).toBe(file); + }); + }); + + describe("coerceArray", () => { + it("should return empty array for empty strings", () => { + expect(coerceArray("")).toEqual([]); + }); + + it("should pass through arrays unchanged", () => { + const arr = [1, 2, 3]; + expect(coerceArray(arr)).toBe(arr); + }); + + it("should wrap non-arrays in an array", () => { + expect(coerceArray("hello")).toEqual(["hello"]); + expect(coerceArray(42)).toEqual([42]); + expect(coerceArray(null)).toEqual([null]); + }); + }); +}); From 02bd7b62dd928f87f1a13f69c5cdb2449e719569 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 11:58:56 +0200 Subject: [PATCH 12/24] refactor schema tests --- test/valibot/schemas.test.ts | 256 ++++++++----------------------- test/zod/schemas.test.ts | 282 ++++++++++------------------------- 2 files changed, 144 insertions(+), 394 deletions(-) diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts index b61eb0c..ca8d8dc 100644 --- a/test/valibot/schemas.test.ts +++ b/test/valibot/schemas.test.ts @@ -2,23 +2,9 @@ import { describe, it, expect } from "vitest"; import * as vf from "../../src/valibot/schemas.js"; import * as v from "valibot"; -describe("valibot schemas preprocessing", () => { +describe("valibot schemas integration", () => { describe("string", () => { - it("should return undefined for empty strings", () => { - const schema = vf.string(); - const result = v.safeParse(schema, ""); - expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = vf.string(); - const result = v.safeParse(schema, 123); - expect(result.success).toBe(false); - expect(result.output).toBe(123); - }); - - it("should return non-empty strings as-is", () => { + it("should work with valid strings", () => { const schema = vf.string(); const result = v.safeParse(schema, "hello"); expect(result.success).toBe(true); @@ -26,24 +12,22 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBe("hello"); } }); - }); - describe("number", () => { - it("should return undefined for empty strings", () => { - const schema = vf.number(); - const result = v.safeParse(schema, ""); + it("should handle validation errors", () => { + const schema = v.pipe(vf.string(), v.minLength(5)); + const result = v.safeParse(schema, "hi"); expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); }); - it("should return undefined for whitespace-only strings", () => { - const schema = vf.number(); - const result = v.safeParse(schema, " "); + it("should work with form data coercion", () => { + const schema = vf.string(); + const result = v.safeParse(schema, ""); expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); }); + }); - it("should pass through non-string values unchanged", () => { + describe("number", () => { + it("should work with valid numbers", () => { const schema = vf.number(); const result = v.safeParse(schema, 42); expect(result.success).toBe(true); @@ -52,7 +36,13 @@ describe("valibot schemas preprocessing", () => { } }); - it("should convert string numbers to numbers", () => { + it("should handle validation errors", () => { + const schema = v.pipe(vf.number(), v.minValue(10)); + const result = v.safeParse(schema, 5); + expect(result.success).toBe(false); + }); + + it("should work with form data coercion", () => { const schema = vf.number(); const result = v.safeParse(schema, "123"); expect(result.success).toBe(true); @@ -60,81 +50,58 @@ describe("valibot schemas preprocessing", () => { expect(result.output).toBe(123); } }); + }); - it("should handle invalid number strings", () => { - const schema = vf.number(); - const result = v.safeParse(schema, "abc"); - expect(result.success).toBe(false); - expect(result.output).toBeNaN(); + describe("bigint", () => { + it("should work with valid bigints", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, 42n); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(42n); + } }); - }); - describe("boolean", () => { - it("should return undefined for empty strings", () => { - const schema = vf.boolean(); - const result = v.safeParse(schema, ""); + it("should handle validation errors", () => { + const schema = v.pipe(vf.bigint(), v.minValue(10n)); + const result = v.safeParse(schema, 5n); expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); }); - it("should pass through non-string values unchanged", () => { - const schema = vf.boolean(); - const result = v.safeParse(schema, true); + it("should work with form data coercion", () => { + const schema = vf.bigint(); + const result = v.safeParse(schema, "123"); expect(result.success).toBe(true); if (result.success) { - expect(result.output).toBe(true); + expect(result.output).toBe(123n); } }); + }); - it("should return true for truthy string values", () => { - const schema = vf.boolean(); - const truthyValues = ["true", "on", "1", "yes"]; - - truthyValues.forEach((value) => { - const result = v.safeParse(schema, value); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(true); - } - }); - }); - - it("should return false for explicit falsy string values", () => { + describe("boolean", () => { + it("should work with valid booleans", () => { const schema = vf.boolean(); - const falsyValues = ["false", "off", "0", "no"]; - - falsyValues.forEach((value) => { - const result = v.safeParse(schema, value); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(false); - } - }); + const result = v.safeParse(schema, true); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(true); + } }); - it("should fail validation for ambiguous string values", () => { + it("should work with form data coercion", () => { const schema = vf.boolean(); - const ambiguousValues = ["maybe", "hello", "banana", "sometimes"]; - - ambiguousValues.forEach((value) => { - const result = v.safeParse(schema, value); - expect(result.success).toBe(false); - expect(result.output).toBe(value); - }); + const result = v.safeParse(schema, "true"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(true); + } }); }); describe("date", () => { - it("should return undefined for empty strings", () => { + it("should work with valid dates", () => { const schema = vf.date(); - const result = v.safeParse(schema, ""); - expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = vf.date(); - const date = new Date(); + const date = new Date("2023-01-01"); const result = v.safeParse(schema, date); expect(result.success).toBe(true); if (result.success) { @@ -142,67 +109,18 @@ describe("valibot schemas preprocessing", () => { } }); - it("should convert string dates to Date objects", () => { + it("should work with form data coercion", () => { const schema = vf.date(); const result = v.safeParse(schema, "2023-01-01"); expect(result.success).toBe(true); if (result.success) { expect(result.output).toBeInstanceOf(Date); - expect(result.output.getFullYear()).toBe(2023); - } - }); - - it("should handle invalid date strings", () => { - const schema = vf.date(); - const result = v.safeParse(schema, "not-a-date"); - expect(result.success).toBe(false); - expect(result.output).toBe("not-a-date"); - }); - }); - - describe("file", () => { - it("should return undefined for empty files", () => { - const schema = vf.file(); - const emptyFile = new File([], "empty.txt"); - const result = v.safeParse(schema, emptyFile); - expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); - }); - - it("should pass through non-File values unchanged", () => { - const schema = vf.file(); - const result = v.safeParse(schema, "not-a-file"); - expect(result.success).toBe(false); - expect(result.output).toBe("not-a-file"); - }); - - it("should return valid files as-is", () => { - const schema = vf.file(); - const file = new File(["content"], "test.txt"); - const result = v.safeParse(schema, file); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(file); } }); }); describe("picklist", () => { - it("should return undefined for empty strings", () => { - const schema = vf.picklist(["a", "b", "c"]); - const result = v.safeParse(schema, ""); - expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = vf.picklist(["a", "b", "c"]); - const result = v.safeParse(schema, 123); - expect(result.success).toBe(false); - expect(result.output).toBe(123); - }); - - it("should return valid picklist values as-is", () => { + it("should work with valid picklist values", () => { const schema = vf.picklist(["a", "b", "c"]); const result = v.safeParse(schema, "a"); expect(result.success).toBe(true); @@ -211,66 +129,27 @@ describe("valibot schemas preprocessing", () => { } }); - it("should handle invalid picklist values", () => { + it("should handle validation errors", () => { const schema = vf.picklist(["a", "b", "c"]); const result = v.safeParse(schema, "d"); expect(result.success).toBe(false); - expect(result.output).toBe("d"); }); }); - describe("bigint", () => { - it("should return undefined for empty strings", () => { - const schema = vf.bigint(); - const result = v.safeParse(schema, ""); - expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); - }); - - it("should return undefined for whitespace-only strings", () => { - const schema = vf.bigint(); - const result = v.safeParse(schema, " "); - expect(result.success).toBe(false); - expect(result.output).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = vf.bigint(); - const result = v.safeParse(schema, 42n); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(42n); - } - }); - - it("should convert string numbers to bigint", () => { - const schema = vf.bigint(); - const result = v.safeParse(schema, "123"); + describe("file", () => { + it("should work with valid files", () => { + const schema = vf.file(); + const file = new File(["content"], "test.txt"); + const result = v.safeParse(schema, file); expect(result.success).toBe(true); if (result.success) { - expect(result.output).toBe(123n); + expect(result.output).toBe(file); } }); - - it("should handle invalid bigint strings", () => { - const schema = vf.bigint(); - const result = v.safeParse(schema, "abc"); - expect(result.success).toBe(false); - expect(result.output).toBe("abc"); - }); }); describe("array", () => { - it("should return empty array for empty strings", () => { - const schema = vf.array(vf.string()); - const result = v.safeParse(schema, ""); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toEqual([]); - } - }); - - it("should pass through arrays unchanged", () => { + it("should work with valid arrays", () => { const schema = vf.array(vf.string()); const result = v.safeParse(schema, ["a", "b", "c"]); expect(result.success).toBe(true); @@ -279,21 +158,12 @@ describe("valibot schemas preprocessing", () => { } }); - it("should convert single values to single-item arrays", () => { + it("should work with form data coercion", () => { const schema = vf.array(vf.string()); - const result = v.safeParse(schema, "hello"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toEqual(["hello"]); - } - }); - - it("should validate array elements", () => { - const schema = vf.array(vf.number()); - const result = v.safeParse(schema, ["123", "456"]); + const result = v.safeParse(schema, "single-value"); expect(result.success).toBe(true); if (result.success) { - expect(result.output).toEqual([123, 456]); + expect(result.output).toEqual(["single-value"]); } }); }); diff --git a/test/zod/schemas.test.ts b/test/zod/schemas.test.ts index 397f13d..9023ff3 100644 --- a/test/zod/schemas.test.ts +++ b/test/zod/schemas.test.ts @@ -1,23 +1,10 @@ import { describe, it, expect } from "vitest"; import * as zf from "../../src/zod/schemas.js"; +import * as z from "zod"; -describe("zod schemas preprocessing", () => { +describe("zod schemas integration", () => { describe("string", () => { - it("should return undefined for empty strings", () => { - const schema = zf.string(); - const result = schema.safeParse(""); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = zf.string(); - const result = schema.safeParse(123); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should return non-empty strings as-is", () => { + it("should work with valid strings", () => { const schema = zf.string(); const result = schema.safeParse("hello"); expect(result.success).toBe(true); @@ -25,24 +12,22 @@ describe("zod schemas preprocessing", () => { expect(result.data).toBe("hello"); } }); - }); - describe("number", () => { - it("should return undefined for empty strings", () => { - const schema = zf.number(); - const result = schema.safeParse(""); + it("should handle validation errors", () => { + const schema = z.string().min(5); + const result = schema.safeParse("hi"); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); }); - it("should return undefined for whitespace-only strings", () => { - const schema = zf.number(); - const result = schema.safeParse(" "); + it("should work with form data coercion", () => { + const schema = zf.string(); + const result = schema.safeParse(""); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); }); + }); - it("should pass through non-string values unchanged", () => { + describe("number", () => { + it("should work with valid numbers", () => { const schema = zf.number(); const result = schema.safeParse(42); expect(result.success).toBe(true); @@ -51,7 +36,13 @@ describe("zod schemas preprocessing", () => { } }); - it("should convert string numbers to numbers", () => { + it("should handle validation errors", () => { + const schema = z.number().min(10); + const result = schema.safeParse(5); + expect(result.success).toBe(false); + }); + + it("should work with form data coercion", () => { const schema = zf.number(); const result = schema.safeParse("123"); expect(result.success).toBe(true); @@ -61,59 +52,56 @@ describe("zod schemas preprocessing", () => { }); }); - describe("boolean", () => { - it("should return undefined for empty strings", () => { - const schema = zf.boolean(); - const result = schema.safeParse(""); + describe("bigint", () => { + it("should work with valid bigints", () => { + const schema = zf.bigint(); + const result = schema.safeParse(42n); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42n); + } + }); + + it("should handle validation errors", () => { + const schema = z.bigint().min(10n); + const result = schema.safeParse(5n); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); }); - it("should pass through non-string values unchanged", () => { - const schema = zf.boolean(); - const result = schema.safeParse(true); + it("should work with form data coercion", () => { + const schema = zf.bigint(); + const result = schema.safeParse("123"); expect(result.success).toBe(true); if (result.success) { - expect(result.data).toBe(true); + expect(result.data).toBe(123n); } }); + }); - it("should return true for truthy string values", () => { + describe("boolean", () => { + it("should work with valid booleans", () => { const schema = zf.boolean(); - const truthyValues = ["true", "on", "1", "yes"]; - - truthyValues.forEach((value) => { - const result = schema.safeParse(value); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toBe(true); - } - }); + const result = schema.safeParse(true); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } }); - it("should return false for non-truthy string values", () => { + it("should work with form data coercion", () => { const schema = zf.boolean(); - const falsyValues = ["false", "off", "0", "no", "maybe", "hello"]; - - falsyValues.forEach((value) => { - const result = schema.safeParse(value); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); + const result = schema.safeParse("true"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } }); }); describe("date", () => { - it("should return undefined for empty strings", () => { - const schema = zf.date(); - const result = schema.safeParse(""); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { + it("should work with valid dates", () => { const schema = zf.date(); - const date = new Date(); + const date = new Date("2023-01-01"); const result = schema.safeParse(date); expect(result.success).toBe(true); if (result.success) { @@ -121,94 +109,47 @@ describe("zod schemas preprocessing", () => { } }); - it("should convert string dates to Date objects", () => { + it("should work with form data coercion", () => { const schema = zf.date(); const result = schema.safeParse("2023-01-01"); expect(result.success).toBe(true); if (result.success) { expect(result.data).toBeInstanceOf(Date); - expect(result.data.getFullYear()).toBe(2023); } }); - - it("should handle invalid date strings gracefully", () => { - const schema = zf.date(); - const result = schema.safeParse("not-a-date"); - expect(result.success).toBe(false); - expect(result.error?.issues[0].message).toBe( - "Invalid input: expected date, received string", - ); - }); }); - describe("file", () => { - it("should return undefined for empty files", () => { - const schema = zf.file(); - const emptyFile = new File([], "empty.txt"); - const result = schema.safeParse(emptyFile); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-File values unchanged", () => { - const schema = zf.file(); - const result = schema.safeParse("not-a-file"); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should return valid files as-is", () => { - const schema = zf.file(); - const file = new File(["content"], "test.txt"); - const result = schema.safeParse(file); + describe("enum", () => { + it("should work with valid enum values", () => { + const schema = zf.enum_(["a", "b", "c"]); + const result = schema.safeParse("a"); expect(result.success).toBe(true); if (result.success) { - expect(result.data).toBe(file); + expect(result.data).toBe("a"); } }); - }); - - describe("enum", () => { - it("should return undefined for empty strings", () => { - const schema = zf.enum_(["a", "b", "c"]); - const result = schema.safeParse(""); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - it("should pass through non-string values unchanged", () => { + it("should handle validation errors", () => { const schema = zf.enum_(["a", "b", "c"]); - const result = schema.safeParse(123); + const result = schema.safeParse("d"); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); }); + }); - it("should return valid enum values as-is", () => { - const schema = zf.enum_(["a", "b", "c"]); - const result = schema.safeParse("a"); + describe("file", () => { + it("should work with valid files", () => { + const schema = zf.file(); + const file = new File(["content"], "test.txt"); + const result = schema.safeParse(file); expect(result.success).toBe(true); if (result.success) { - expect(result.data).toBe("a"); + expect(result.data).toBe(file); } }); }); describe("email", () => { - it("should return undefined for empty strings", () => { - const schema = zf.email(); - const result = schema.safeParse(""); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = zf.email(); - const result = schema.safeParse(123); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should return valid email strings as-is", () => { + it("should work with valid emails", () => { const schema = zf.email(); const result = schema.safeParse("test@example.com"); expect(result.success).toBe(true); @@ -216,24 +157,16 @@ describe("zod schemas preprocessing", () => { expect(result.data).toBe("test@example.com"); } }); - }); - describe("url", () => { - it("should return undefined for empty strings", () => { - const schema = zf.url(); - const result = schema.safeParse(""); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = zf.url(); - const result = schema.safeParse(123); + it("should handle validation errors", () => { + const schema = zf.email(); + const result = schema.safeParse("invalid-email"); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); }); + }); - it("should return valid URL strings as-is", () => { + describe("url", () => { + it("should work with valid URLs", () => { const schema = zf.url(); const result = schema.safeParse("https://example.com"); expect(result.success).toBe(true); @@ -241,60 +174,16 @@ describe("zod schemas preprocessing", () => { expect(result.data).toBe("https://example.com"); } }); - }); - - describe("bigint", () => { - it("should return undefined for empty strings", () => { - const schema = zf.bigint(); - const result = schema.safeParse(""); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should return undefined for whitespace-only strings", () => { - const schema = zf.bigint(); - const result = schema.safeParse(" "); - expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = zf.bigint(); - const result = schema.safeParse(42n); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toBe(42n); - } - }); - it("should convert string numbers to bigint", () => { - const schema = zf.bigint(); - const result = schema.safeParse("123"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toBe(123n); - } - }); - - it("should handle invalid bigint strings gracefully", () => { - const schema = zf.bigint(); - const result = schema.safeParse("abc"); + it("should handle validation errors", () => { + const schema = zf.url(); + const result = schema.safeParse("not-a-url"); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); }); }); describe("array", () => { - it("should return empty array for empty strings", () => { - const schema = zf.array(zf.string()); - const result = schema.safeParse(""); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual([]); - } - }); - - it("should pass through arrays unchanged", () => { + it("should work with valid arrays", () => { const schema = zf.array(zf.string()); const result = schema.safeParse(["a", "b", "c"]); expect(result.success).toBe(true); @@ -303,21 +192,12 @@ describe("zod schemas preprocessing", () => { } }); - it("should convert single values to single-item arrays", () => { + it("should work with form data coercion", () => { const schema = zf.array(zf.string()); - const result = schema.safeParse("hello"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(["hello"]); - } - }); - - it("should validate array elements", () => { - const schema = zf.array(zf.number()); - const result = schema.safeParse(["123", "456"]); + const result = schema.safeParse("single-value"); expect(result.success).toBe(true); if (result.success) { - expect(result.data).toEqual([123, 456]); + expect(result.data).toEqual(["single-value"]); } }); }); From 63d6c9378d78d620be88c9f18c68f1aa9a350a9d Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:01:54 +0200 Subject: [PATCH 13/24] Deprecate zod utilities --- src/zod/schemas.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/zod/schemas.ts b/src/zod/schemas.ts index c3070bf..aafddaa 100644 --- a/src/zod/schemas.ts +++ b/src/zod/schemas.ts @@ -9,26 +9,44 @@ import { coerceArray, } from "../coerce.js"; +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function string(params?: Parameters[0]) { return z.preprocess(coerceString, z.string(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function number(params?: Parameters[0]) { return z.preprocess(coerceNumber, z.number(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function bigint(params?: Parameters[0]) { return z.preprocess(coerceBigint, z.bigint(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function boolean(params?: Parameters[0]) { return z.preprocess(coerceBoolean, z.boolean(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function date(params?: Parameters[0]) { return z.preprocess(coerceDate, z.date(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function enum_( values: T, params?: Parameters[1], @@ -36,20 +54,35 @@ export function enum_( return z.preprocess(coerceString, z.enum(values, params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function file(params?: Parameters[0]) { return z.preprocess(coerceFile, z.file(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function email(params?: Parameters[0]) { return z.preprocess(coerceString, z.email(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function url(params?: Parameters[0]) { return z.preprocess(coerceString, z.url(params)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export const object = z.object; +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ export function array( element: T, params?: Parameters[1], From e710beb4ba92f404ee1d3e85e06e9cd0b61ac7de Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:03:56 +0200 Subject: [PATCH 14/24] Fix zod tests --- test/zod/schemas.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/test/zod/schemas.test.ts b/test/zod/schemas.test.ts index 9023ff3..93d8e73 100644 --- a/test/zod/schemas.test.ts +++ b/test/zod/schemas.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; import * as zf from "../../src/zod/schemas.js"; -import * as z from "zod"; describe("zod schemas integration", () => { describe("string", () => { @@ -14,8 +13,8 @@ describe("zod schemas integration", () => { }); it("should handle validation errors", () => { - const schema = z.string().min(5); - const result = schema.safeParse("hi"); + const schema = zf.string(); + const result = schema.safeParse(true); expect(result.success).toBe(false); }); @@ -37,8 +36,8 @@ describe("zod schemas integration", () => { }); it("should handle validation errors", () => { - const schema = z.number().min(10); - const result = schema.safeParse(5); + const schema = zf.number(); + const result = schema.safeParse("hello"); expect(result.success).toBe(false); }); @@ -63,8 +62,8 @@ describe("zod schemas integration", () => { }); it("should handle validation errors", () => { - const schema = z.bigint().min(10n); - const result = schema.safeParse(5n); + const schema = zf.bigint(); + const result = schema.safeParse("hello"); expect(result.success).toBe(false); }); From 063272c477db528872722d3ea03fe5c23e0ea654 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:05:28 +0200 Subject: [PATCH 15/24] Export coerce module --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9bc700b..1b493ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,12 @@ export { decode } from "./decode.js"; export { parseFormData } from "./parse.js"; export { serialize } from "./serialize.js"; export type { PathsFromObject, Submission } from "./types.js"; +export { + coerceArray, + coerceBigint, + coerceBoolean, + coerceDate, + coerceFile, + coerceNumber, + coerceString, +} from "./coerce.js"; From e268c0594e6dd7422798fe1a1cdd1067923ba16c Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:16:19 +0200 Subject: [PATCH 16/24] Update package.json --- package-lock.json | 12 ++++++------ package.json | 14 +++++--------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1454263..ea3e12e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "prettier": "^3.6.2", "typescript": "^5.9.2", "valibot": "^1.1.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "zod": "^4.1.11" }, "peerDependencies": { "valibot": "^1.1.0", @@ -2963,12 +2964,11 @@ } }, "node_modules/zod": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.1.tgz", - "integrity": "sha512-SgMZK/h8Tigt9nnKkfJMvB/mKjiJXaX26xegP4sa+0wHIFVFWVlsQGdhklDmuargBD3Hsi3rsQRIzwJIhTPJHA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "dev": true, "license": "MIT", - "optional": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 6e706b9..7b89ecd 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,9 @@ "types": "./dist/index.d.ts", "main": "./dist/index.js", "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./zod": { - "types": "./dist/zod/index.d.ts", - "default": "./dist/zod/index.js" - } + ".": "./dist/index.js", + "./valibot": "./dist/valibot/index.js", + "./zod": "./dist/zod/index.js" }, "scripts": { "build": "tsc", @@ -52,7 +47,8 @@ "prettier": "^3.6.2", "typescript": "^5.9.2", "valibot": "^1.1.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "zod": "^4.1.11" }, "peerDependencies": { "valibot": "^1.1.0", From 999d6a3f08859fe8cf54f50ac36ddfd1d80997f5 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:16:23 +0200 Subject: [PATCH 17/24] Align readme --- README.md | 6 +++--- src/valibot/README.md | 27 +++++++++++++++++++++++++++ src/zod/README.md | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/valibot/README.md diff --git a/README.md b/README.md index 12a5581..7e9cecd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Here's a quick example showing how Conformal handles form validation with a user ```typescript import { parseFormData } from "conformal"; -import * as z from "zod"; // Tip: Use conformal/zod for automatic form input preprocessing +import * as z from "zod"; // Tip: Use conformal's coerce functions for form input preprocessing const schema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), @@ -75,11 +75,11 @@ That's it! Conformal automatically handles FormData parsing, type coercion, and - **[`Submission`](src/README.md#submission)** - Standardized submission result with success/error states - **[`PathsFromObject`](src/README.md#pathsfromobject)** - Type utility to extract all possible object paths -### Zod Utilities +### Valibot Utilities > ⚠️ **Experimental**: These utilities are still in development and may change. -- **[Zod Field Schemas](src/zod/README.md#field-schemas)** - Zod schemas with automatic form input preprocessing +- **[Valibot Field Schemas](src/valibot/README.md#field-schemas)** - Valibot schemas with automatic form input preprocessing ## License diff --git a/src/valibot/README.md b/src/valibot/README.md new file mode 100644 index 0000000..6b5bbc6 --- /dev/null +++ b/src/valibot/README.md @@ -0,0 +1,27 @@ +# Valibot Utilities + +> ⚠️ **Experimental**: These utilities are still in development and may change. + +The Valibot Utilities are provided under the `conformal/valibot` subpath. Valibot is an optional peer dependency, so you can freely choose another Standard Schema library if you prefer without depending on Valibot. + +## Field Schemas + +Conformal's field schemas are preprocessing wrappers that handle common form input patterns automatically. They convert empty strings to `undefined`, coerce string inputs to appropriate types (numbers, dates, booleans), and handle `File` objects. They're fully compatible with Valibot and can be mixed with regular Valibot schemas. + +```typescript +import * as vf from "conformal/valibot"; +import * as v from "valibot"; + +v.object({ + name: v.optional(vf.string()), + email: v.pipe(vf.string(), v.email()), + age: v.pipe(vf.number(), v.minValue(16)), + hobbies: vf.array(vf.string()), + birthDate: vf.date(), + acceptTerms: vf.boolean(), + profilePicture: vf.file(), + accountType: vf.picklist(["personal", "business"]), + website: v.optional(v.pipe(vf.string(), v.url())), + transactionAmount: vf.bigint(), +}); +``` diff --git a/src/zod/README.md b/src/zod/README.md index c33cfa5..9bd46a6 100644 --- a/src/zod/README.md +++ b/src/zod/README.md @@ -1,6 +1,6 @@ # Zod Utilities -> ⚠️ **Experimental**: These utilities are still in development and may change. +> ⚠️ **Deprecated**: These utilities are deprecated and will be removed in the next major release. The Zod Utilities are provided under the `conformal/zod` subpath. Zod is an optional peer dependency, so you can freely choose another Standard Schema library if you prefer without depending on Zod. From 7c6573debf918bce40839bf90a6d48cf8bd7cd0e Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:30:16 +0200 Subject: [PATCH 18/24] Update documentation --- AGENTS.md | 7 ++-- README.md | 10 +++++ src/README.md | 113 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 056e5aa..ce63be1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,10 +19,11 @@ cd examples/svelte && npm i && npm run dev ## Public API -- `conformal`: `getPath`, `setPath`, `decode`, `parseFormData`, `serialize`; types: `PathsFromObject`, `Submission` -- `conformal/zod`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email`, `object`, `array` +- `conformal`: `getPath`, `setPath`, `decode`, `parseFormData`, `serialize`, `coerceString`, `coerceNumber`, `coerceBigint`, `coerceBoolean`, `coerceDate`, `coerceFile`, `coerceArray`; types: `PathsFromObject`, `Submission` +- `conformal/valibot`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email`, `object`, `array` (experimental) +- `conformal/zod`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email`, `object`, `array` (deprecated) -Exports live in `src/index.ts` and `src/zod/index.ts`. +Exports live in `src/index.ts`, `src/valibot/index.ts`, and `src/zod/index.ts`. ## Non‑negotiable invariants diff --git a/README.md b/README.md index 7e9cecd..d7fca64 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,16 @@ That's it! Conformal automatically handles FormData parsing, type coercion, and - **[`getPath`](src/README.md#getpath)** - Safely access nested values using dot/bracket notation - **[`setPath`](src/README.md#setpath)** - Immutably set nested values using dot/bracket notation +### Coerce Functions + +- **[`coerceString`](src/README.md#coercestring)** - String handling +- **[`coerceFile`](src/README.md#coercefile)** - File handling +- **[`coerceNumber`](src/README.md#coercenumber)** - String to number coercion +- **[`coerceBigint`](src/README.md#coercebigint)** - String to BigInt coercion +- **[`coerceBoolean`](src/README.md#coerceboolean)** - String to boolean coercion +- **[`coerceDate`](src/README.md#coercedate)** - String to Date coercion +- **[`coerceArray`](src/README.md#coercearray)** - Coerce to array + ### Types - **[`Submission`](src/README.md#submission)** - Standardized submission result with success/error states diff --git a/src/README.md b/src/README.md index 24da610..0fc2ec7 100644 --- a/src/README.md +++ b/src/README.md @@ -2,17 +2,25 @@ ### Table of Contents -- [Functions](#functions) +- [Core Functions](#core-functions) - [parseFormData](#parseformdata) - [decode](#decode) - [serialize](#serialize) - [getPath](#getpath) - [setPath](#setpath) +- [Coerce Functions](#coerce-functions) + - [coerceString](#coercestring) + - [coerceNumber](#coercenumber) + - [coerceBigint](#coercebigint) + - [coerceBoolean](#coerceboolean) + - [coerceDate](#coercedate) + - [coerceFile](#coercefile) + - [coerceArray](#coercearray) - [Types](#types) - [Submission](#submission) - [PathsFromObject](#pathsfromobject) -## Functions +## Core Functions ### parseFormData @@ -107,6 +115,107 @@ const newObj = setPath({ a: { b: { c: [] } } }, "a.b.c[1]", "hey"); // Returns { a: { b: { c: [, 'hey'] } } } ``` +## Coerce Functions + +The coerce functions provide utilities for converting form input values to their expected types. These functions are essential for building custom schema implementations (like zod or valibot schemas) where you need to transform string-based form data into proper JavaScript types before validation. All coerce functions handle empty strings by returning `undefined` and pass through non-matching types unchanged, making them safe to use in schema transformation pipelines. + +### coerceString + +Converts string input to a string value, returning `undefined` for empty strings. + +```typescript +import { coerceString } from "conformal"; + +console.log(coerceString("hello")); // "hello" +console.log(coerceString("")); // undefined +console.log(coerceString(123)); // 123 (unchanged) +``` + +### coerceNumber + +Converts string input to a number, returning `undefined` for empty or whitespace-only strings. + +```typescript +import { coerceNumber } from "conformal"; + +console.log(coerceNumber("42")); // 42 +console.log(coerceNumber("3.14")); // 3.14 +console.log(coerceNumber("")); // undefined +console.log(coerceNumber(" ")); // undefined +console.log(coerceNumber("abc")); // NaN +``` + +### coerceBigint + +Converts string input to a BigInt, returning `undefined` for empty or whitespace-only strings. Returns the original string if conversion fails. + +```typescript +import { coerceBigint } from "conformal"; + +console.log(coerceBigint("42")); // 42n +console.log(coerceBigint("9007199254740991")); // 9007199254740991n +console.log(coerceBigint("")); // undefined +console.log(coerceBigint("abc")); // "abc" (unchanged) +``` + +### coerceBoolean + +Converts string input to a boolean based on common truthy/falsy string values. + +```typescript +import { coerceBoolean } from "conformal"; + +console.log(coerceBoolean("true")); // true +console.log(coerceBoolean("on")); // true +console.log(coerceBoolean("1")); // true +console.log(coerceBoolean("yes")); // true +console.log(coerceBoolean("false")); // false +console.log(coerceBoolean("off")); // false +console.log(coerceBoolean("0")); // false +console.log(coerceBoolean("no")); // false +console.log(coerceBoolean("")); // undefined +console.log(coerceBoolean("maybe")); // "maybe" (unchanged) +``` + +### coerceDate + +Converts string input to a Date object, returning `undefined` for empty strings. Returns the original string if the date is invalid. + +```typescript +import { coerceDate } from "conformal"; + +console.log(coerceDate("2023-01-01")); // Date object +console.log(coerceDate("")); // undefined +console.log(coerceDate("invalid-date")); // "invalid-date" (unchanged) +``` + +### coerceFile + +Handles File objects, returning `undefined` for empty files (size 0). + +```typescript +import { coerceFile } from "conformal"; + +const emptyFile = new File([], "test.txt"); +const file = new File(["content"], "test.txt"); + +console.log(coerceFile(emptyFile)); // undefined +console.log(coerceFile(file)); // File object +console.log(coerceFile("not-a-file")); // "not-a-file" (unchanged) +``` + +### coerceArray + +Converts any input to an array. Empty strings become empty arrays, arrays pass through unchanged, and other values are wrapped in an array. + +```typescript +import { coerceArray } from "conformal"; + +console.log(coerceArray("")); // [] +console.log(coerceArray([1, 2, 3])); // [1, 2, 3] +console.log(coerceArray("hello")); // ["hello"] +``` + ## Types ### Submission From 2149115bac76af036ea418bdc4d9e2935c641f40 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:34:40 +0200 Subject: [PATCH 19/24] Fix AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ce63be1..f0a33a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ cd examples/svelte && npm i && npm run dev ## Public API - `conformal`: `getPath`, `setPath`, `decode`, `parseFormData`, `serialize`, `coerceString`, `coerceNumber`, `coerceBigint`, `coerceBoolean`, `coerceDate`, `coerceFile`, `coerceArray`; types: `PathsFromObject`, `Submission` -- `conformal/valibot`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email`, `object`, `array` (experimental) +- `conformal/valibot`: `string`, `number`, `boolean`, `date`, `bigint`, `picklist`, `file`, `array` (experimental) - `conformal/zod`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email`, `object`, `array` (deprecated) Exports live in `src/index.ts`, `src/valibot/index.ts`, and `src/zod/index.ts`. From 2581ac555ade4aa095cadeabb7087a26ed454380 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:40:35 +0200 Subject: [PATCH 20/24] Add coerce fn changeset --- .changeset/huge-planets-yawn.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/huge-planets-yawn.md diff --git a/.changeset/huge-planets-yawn.md b/.changeset/huge-planets-yawn.md new file mode 100644 index 0000000..0dde656 --- /dev/null +++ b/.changeset/huge-planets-yawn.md @@ -0,0 +1,9 @@ +--- +"conformal": minor +--- + +Add coerce functions + +- Add `coerceString`, `coerceNumber`, `coerceBigint`, `coerceBoolean`, `coerceDate`, `coerceFile`, `coerceArray` functions +- These utilities help convert form input values to their expected types +- Essential for building custom schema implementations (like zod preproccessors or valibot transforms) From 27ed9d3dc32a05ee7dbca61b410724809a9f9f38 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:42:03 +0200 Subject: [PATCH 21/24] Add valibot changeset --- .changeset/rich-feet-throw.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/rich-feet-throw.md diff --git a/.changeset/rich-feet-throw.md b/.changeset/rich-feet-throw.md new file mode 100644 index 0000000..5f9dd2e --- /dev/null +++ b/.changeset/rich-feet-throw.md @@ -0,0 +1,11 @@ +--- +"conformal": minor +--- + +Add valibot schemas + +- Add `conformal/valibot` subpath with valibot utilities +- Provides `string`, `number`, `boolean`, `date`, `bigint`, `picklist`, `file`, `array` schemas +- Uses conformal's coerce functions for automatic form input preprocessing +- Fully compatible with valibot and can be mixed with regular valibot schemas +- Marked as experimental - API may change From 80724061dc3029085572303fb75275f881f10274 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:49:09 +0200 Subject: [PATCH 22/24] Add zod deprecation changeset --- .changeset/many-beds-tap.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/many-beds-tap.md diff --git a/.changeset/many-beds-tap.md b/.changeset/many-beds-tap.md new file mode 100644 index 0000000..8ed4c4e --- /dev/null +++ b/.changeset/many-beds-tap.md @@ -0,0 +1,17 @@ +--- +"conformal": minor +--- + +Deprecate zod utilities + +- Mark `conformal/zod` utilities as deprecated +- Zod's `z.preprocess` returns a `ZodPipe` which doesn't allow method chaining, making these utilities less useful than expected +- Zod utilities will be removed in the next major release +- Users can migrate to using z.preprocess with the new coerce functions directly: + +```typescript +import * as z from "zod"; +import { coerceNumber } from "conformal"; + +z.preprocess(coerceNumber, z.number().min(5)); +``` From 063d7c4264c110621cd6fb7c79cf94898c34c55e Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:53:38 +0200 Subject: [PATCH 23/24] Add serialize changeset --- .changeset/dry-cats-lay.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dry-cats-lay.md diff --git a/.changeset/dry-cats-lay.md b/.changeset/dry-cats-lay.md new file mode 100644 index 0000000..1faa958 --- /dev/null +++ b/.changeset/dry-cats-lay.md @@ -0,0 +1,5 @@ +--- +"conformal": patch +--- + +Align serialize false boolean behavior with coerceBoolean From 3b3c1453253908dcfc26204a0edc561a2d933086 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 12:56:19 +0200 Subject: [PATCH 24/24] Lower valibot peerDependency version --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea3e12e..35864b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "zod": "^4.1.11" }, "peerDependencies": { - "valibot": "^1.1.0", + "valibot": "^1.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { diff --git a/package.json b/package.json index 7b89ecd..fecfb3b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "zod": "^4.1.11" }, "peerDependencies": { - "valibot": "^1.1.0", + "valibot": "^1.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": {