diff --git a/.changeset/all-mangos-smell.md b/.changeset/all-mangos-smell.md new file mode 100644 index 0000000..bf5a5b1 --- /dev/null +++ b/.changeset/all-mangos-smell.md @@ -0,0 +1,5 @@ +--- +"conformal": patch +--- + +Align coerceNumber invalid input handling diff --git a/.changeset/gold-numbers-tap.md b/.changeset/gold-numbers-tap.md new file mode 100644 index 0000000..65fdfa1 --- /dev/null +++ b/.changeset/gold-numbers-tap.md @@ -0,0 +1,5 @@ +--- +"conformal": patch +--- + +Refactored valibot utilities from schemas to composable coercion pipes for form input preprocessing. diff --git a/AGENTS.md b/AGENTS.md index f0a33a8..2181532 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`, `picklist`, `file`, `array` (experimental) +- `conformal/valibot`: `coerceString`, `coerceNumber`, `coerceBoolean`, `coerceDate`, `coerceBigint`, `coerceFile`, `coerceArray` (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`. @@ -53,6 +53,6 @@ npx vitest run test/parse.test.ts ## Quick playbooks -- Add a Zod helper: edit `src/zod/schemas.ts`, re-export in `src/zod/index.ts`, add tests in `test/zod/`, update README's Zod section. +- Add a Valibot coercion pipe: edit `src/valibot/coerce.ts`, re-export in `src/valibot/index.ts`, add tests in `test/valibot/coerce.test.ts`, update README's Valibot section. - Fix path bug: add failing test in `test/path.test.ts`, update `src/path.ts` (immutability + guards), run full checks. - Public API change: update `src/index.ts`, tests, README; keep exports stable. diff --git a/README.md b/README.md index 4e5815b..df7acd0 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ That's it! Conformal automatically handles FormData parsing, type coercion, and - **[`serialize`](src/README.md#serialize)** - Transform typed values back to form-compatible strings - **[`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 -- **[`coerceX`](src/README.md#coerce-functions)** - A set of coercion functions for building custom schemas +- **[`coerceX`](src/README.md#coerce-functions)** - A set of coercion functions for use with schema libraries ### Types @@ -80,7 +80,7 @@ That's it! Conformal automatically handles FormData parsing, type coercion, and > ⚠️ **Experimental**: These utilities are still in development and may change. -- **[Valibot Field Schemas](src/valibot/README.md#field-schemas)** - Valibot schemas with automatic form input preprocessing +- **[Coercion Pipes](src/valibot/README.md#coercion-pipes)** - Pipes for automatic form input preprocessing ## License diff --git a/src/README.md b/src/README.md index 0fc2ec7..34f20aa 100644 --- a/src/README.md +++ b/src/README.md @@ -117,7 +117,7 @@ const newObj = setPath({ a: { b: { c: [] } } }, "a.b.c[1]", "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. +The coerce functions provide utilities for converting form input values to their expected types. These functions are essential for building schema library (e.g. zod) utilities 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 @@ -142,7 +142,7 @@ 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 +console.log(coerceNumber("abc")); // "abc" (unchanged) ``` ### coerceBigint @@ -155,6 +155,7 @@ import { coerceBigint } from "conformal"; console.log(coerceBigint("42")); // 42n console.log(coerceBigint("9007199254740991")); // 9007199254740991n console.log(coerceBigint("")); // undefined +console.log(coerceNumber(" ")); // undefined console.log(coerceBigint("abc")); // "abc" (unchanged) ``` diff --git a/src/coerce.ts b/src/coerce.ts index 163ced8..6172907 100644 --- a/src/coerce.ts +++ b/src/coerce.ts @@ -16,7 +16,8 @@ export function coerceNumber(input: unknown): unknown { if (input.trim() === "") { return undefined; } - return Number(input); + const number = Number(input); + return Number.isNaN(number) ? input : number; } export function coerceBigint(input: unknown): unknown { diff --git a/src/valibot/README.md b/src/valibot/README.md index 605cf25..3410745 100644 --- a/src/valibot/README.md +++ b/src/valibot/README.md @@ -4,24 +4,25 @@ 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 +## Coercion Pipes -These field schemas are preprocessing wrappers that handle common form input patterns automatically by using conformal's set of coerce functions internally. 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. +These coercion pipes handle the conversion from form input values to rich JS types. They convert empty strings to `undefined`, coerce string inputs to appropriate types (numbers, dates, booleans), and handle `File` objects. They're composable pipes that can be combined with any valibot validation schema using `v.pipe()`. ```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(), + name: v.pipe(vf.coerceString(), v.string()), + email: v.pipe(vf.coerceString(), v.string(), v.email()), + age: v.pipe(vf.coerceNumber(), v.number(), v.minValue(16)), + hobbies: v.pipe( + vf.coerceArray(), + v.array(v.pipe(vf.coerceString(), v.string())), + ), + birthDate: v.pipe(vf.coerceDate(), v.date()), + acceptTerms: v.pipe(vf.coerceBoolean(), v.boolean()), + profilePicture: v.pipe(vf.coerceFile(), v.file()), + transactionAmount: v.pipe(vf.coerceBigint(), v.bigint()), }); ``` diff --git a/src/valibot/coerce.ts b/src/valibot/coerce.ts new file mode 100644 index 0000000..ce5bbcd --- /dev/null +++ b/src/valibot/coerce.ts @@ -0,0 +1,42 @@ +import * as v from "valibot"; +import * as coerce from "../coerce.js"; + +export function coerceString(): v.SchemaWithPipe< + readonly [v.UnknownSchema, v.TransformAction] +> { + return v.pipe(v.unknown(), v.transform(coerce.coerceString)); +} + +export function coerceNumber(): v.SchemaWithPipe< + readonly [v.UnknownSchema, v.TransformAction] +> { + return v.pipe(v.unknown(), v.transform(coerce.coerceNumber)); +} + +export function coerceBigint() { + return v.pipe(v.unknown(), v.transform(coerce.coerceBigint)); +} + +export function coerceBoolean(): v.SchemaWithPipe< + readonly [v.UnknownSchema, v.TransformAction] +> { + return v.pipe(v.unknown(), v.transform(coerce.coerceBoolean)); +} + +export function coerceDate(): v.SchemaWithPipe< + readonly [v.UnknownSchema, v.TransformAction] +> { + return v.pipe(v.unknown(), v.transform(coerce.coerceDate)); +} + +export function coerceFile(): v.SchemaWithPipe< + readonly [v.UnknownSchema, v.TransformAction] +> { + return v.pipe(v.unknown(), v.transform(coerce.coerceFile)); +} + +export function coerceArray(): v.SchemaWithPipe< + readonly [v.UnknownSchema, v.TransformAction] +> { + return v.pipe(v.unknown(), v.transform(coerce.coerceArray)); +} diff --git a/src/valibot/index.ts b/src/valibot/index.ts index 13ed950..0878c9a 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -1,10 +1,9 @@ export { - string, - number, - boolean, - date, - bigint, - picklist, - file, - array, -} from "./schemas.js"; + coerceArray, + coerceBigint, + coerceBoolean, + coerceDate, + coerceFile, + coerceNumber, + coerceString, +} from "./coerce.js"; diff --git a/src/valibot/schemas.ts b/src/valibot/schemas.ts deleted file mode 100644 index ab9fd1d..0000000 --- a/src/valibot/schemas.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as v from "valibot"; -import { - coerceString, - coerceNumber, - coerceBigint, - coerceBoolean, - coerceDate, - coerceFile, - coerceArray, -} from "../coerce.js"; - -export function string< - const TMessage extends v.ErrorMessage | undefined, ->(message?: TMessage) { - 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(coerceNumber), v.number(message)); -} - -export function bigint< - const TMessage extends v.ErrorMessage | undefined, ->(message?: TMessage) { - 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(coerceBoolean), v.boolean(message)); -} - -export function date< - const TMessage extends v.ErrorMessage | undefined, ->(message?: TMessage) { - return v.pipe(v.unknown(), v.transform(coerceDate), v.date(message)); -} - -export function picklist< - const TOptions extends string[] | Readonly, - const TMessage extends v.ErrorMessage | undefined, ->(options: TOptions, message?: TMessage) { - return v.pipe( - v.unknown(), - v.transform(coerceString), - v.picklist(options, message), - ); -} - -export function file< - const TMessage extends v.ErrorMessage | undefined, ->(message?: TMessage) { - 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(coerceArray), v.array(item, message)); -} diff --git a/test/coerce.test.ts b/test/coerce.test.ts index e8066fe..6fd1b0d 100644 --- a/test/coerce.test.ts +++ b/test/coerce.test.ts @@ -47,9 +47,9 @@ describe("coerce functions", () => { expect(coerceNumber("-10")).toBe(-10); }); - it("should return NaN for invalid number strings", () => { - expect(coerceNumber("abc")).toBeNaN(); - expect(coerceNumber("12.34.56")).toBeNaN(); + it("should return original string for invalid number strings", () => { + expect(coerceNumber("abc")).toBe("abc"); + expect(coerceNumber("12.34.56")).toBe("12.34.56"); }); }); diff --git a/test/valibot/coerce.test.ts b/test/valibot/coerce.test.ts new file mode 100644 index 0000000..7841cd1 --- /dev/null +++ b/test/valibot/coerce.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from "vitest"; +import * as vf from "../../src/valibot/coerce.js"; +import * as v from "valibot"; + +describe("valibot coercion pipes", () => { + describe("coerceString", () => { + it("should coerce valid strings", () => { + const schema = v.pipe(vf.coerceString(), v.string()); + const result = v.safeParse(schema, "hello"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe("hello"); + } + }); + + it("should fail validation for empty strings", () => { + const schema = v.pipe(vf.coerceString(), v.string()); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + }); + + it("should pass validation for empty strings when optional", () => { + const schema = v.pipe(vf.coerceString(), v.optional(v.string())); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeUndefined(); + } + }); + + it("should work with email validation", () => { + const schema = v.pipe(vf.coerceString(), v.string(), v.email()); + const result = v.safeParse(schema, "test@example.com"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe("test@example.com"); + } + }); + + it("should work with URL validation", () => { + const schema = v.pipe(vf.coerceString(), v.string(), v.url()); + const result = v.safeParse(schema, "https://example.com"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe("https://example.com"); + } + }); + }); + + describe("coerceNumber", () => { + it("should coerce string numbers to numbers", () => { + const schema = v.pipe(vf.coerceNumber(), v.number()); + const result = v.safeParse(schema, "123"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(123); + } + }); + + it("should fail validation for empty strings", () => { + const schema = v.pipe(vf.coerceNumber(), v.number()); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + }); + + it("should pass validation for empty strings when optional", () => { + const schema = v.pipe(vf.coerceNumber(), v.optional(v.number())); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeUndefined(); + } + }); + + it("should work with minValue validation", () => { + const schema = v.pipe(vf.coerceNumber(), v.number(), v.minValue(16)); + const result = v.safeParse(schema, "18"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(18); + } + }); + + it("should fail minValue validation", () => { + const schema = v.pipe(vf.coerceNumber(), v.number(), v.minValue(16)); + const result = v.safeParse(schema, "15"); + expect(result.success).toBe(false); + }); + }); + + describe("coerceBigint", () => { + it("should coerce string numbers to bigints", () => { + const schema = v.pipe(vf.coerceBigint(), v.bigint()); + const result = v.safeParse(schema, "123"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(123n); + } + }); + + it("should fail validation for empty strings", () => { + const schema = v.pipe(vf.coerceBigint(), v.bigint()); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + }); + + it("should pass validation for empty strings when optional", () => { + const schema = v.pipe(vf.coerceBigint(), v.optional(v.bigint())); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeUndefined(); + } + }); + }); + + describe("coerceBoolean", () => { + it("should coerce form boolean values", () => { + const schema = v.pipe(vf.coerceBoolean(), v.boolean()); + const result = v.safeParse(schema, "on"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(true); + } + }); + + it("should coerce 'off' to false", () => { + const schema = v.pipe(vf.coerceBoolean(), v.boolean()); + const result = v.safeParse(schema, "off"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBe(false); + } + }); + + it("should fail validation for empty strings", () => { + const schema = v.pipe(vf.coerceBoolean(), v.boolean()); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + }); + + it("should pass validation for empty strings when optional", () => { + const schema = v.pipe(vf.coerceBoolean(), v.optional(v.boolean())); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeUndefined(); + } + }); + }); + + describe("coerceDate", () => { + it("should coerce date strings to Date objects", () => { + const schema = v.pipe(vf.coerceDate(), v.date()); + const result = v.safeParse(schema, "2023-01-01"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeInstanceOf(Date); + } + }); + + it("should fail validation for empty strings", () => { + const schema = v.pipe(vf.coerceDate(), v.date()); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(false); + }); + + it("should pass validation for empty strings when optional", () => { + const schema = v.pipe(vf.coerceDate(), v.optional(v.date())); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeUndefined(); + } + }); + }); + + describe("coerceFile", () => { + it("should handle valid files", () => { + const schema = v.pipe(vf.coerceFile(), v.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); + } + }); + + it("should fail validation for empty files", () => { + const schema = v.pipe(vf.coerceFile(), v.file()); + const file = new File([], "empty.txt"); + const result = v.safeParse(schema, file); + expect(result.success).toBe(false); + }); + + it("should pass validation for files with size 0 when optional", () => { + const schema = v.pipe(vf.coerceFile(), v.optional(v.file())); + const file = new File([], "empty.txt"); + const result = v.safeParse(schema, file); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toBeUndefined(); + } + }); + }); + + describe("coerceArray", () => { + it("should handle valid arrays", () => { + const schema = v.pipe(vf.coerceArray(), v.array(v.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 coerce single values to arrays", () => { + const schema = v.pipe(vf.coerceArray(), v.array(v.string())); + const result = v.safeParse(schema, "single-value"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual(["single-value"]); + } + }); + + it("should coerce empty strings to empty arrays", () => { + const schema = v.pipe(vf.coerceArray(), v.array(v.string())); + const result = v.safeParse(schema, ""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual([]); + } + }); + + it("should work with nested coercion pipes", () => { + const schema = v.pipe( + vf.coerceArray(), + v.array(v.pipe(vf.coerceString(), v.string())), + ); + const result = v.safeParse(schema, ["hello", "world"]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output).toEqual(["hello", "world"]); + } + }); + }); +}); diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts deleted file mode 100644 index ca8d8dc..0000000 --- a/test/valibot/schemas.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect } from "vitest"; -import * as vf from "../../src/valibot/schemas.js"; -import * as v from "valibot"; - -describe("valibot schemas integration", () => { - describe("string", () => { - it("should work with valid strings", () => { - const schema = vf.string(); - const result = v.safeParse(schema, "hello"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe("hello"); - } - }); - - 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); - }); - - it("should work with form data coercion", () => { - const schema = vf.string(); - const result = v.safeParse(schema, ""); - expect(result.success).toBe(false); - }); - }); - - describe("number", () => { - it("should work with valid numbers", () => { - 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 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); - if (result.success) { - expect(result.output).toBe(123); - } - }); - }); - - 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); - } - }); - - 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); - }); - - 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(123n); - } - }); - }); - - describe("boolean", () => { - it("should work with valid booleans", () => { - 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 work with form data coercion", () => { - const schema = vf.boolean(); - const result = v.safeParse(schema, "true"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(true); - } - }); - }); - - describe("date", () => { - it("should work with valid dates", () => { - const schema = vf.date(); - const date = new Date("2023-01-01"); - const result = v.safeParse(schema, date); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toBe(date); - } - }); - - 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); - } - }); - }); - - describe("picklist", () => { - 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); - if (result.success) { - expect(result.output).toBe("a"); - } - }); - - it("should handle validation errors", () => { - const schema = vf.picklist(["a", "b", "c"]); - const result = v.safeParse(schema, "d"); - expect(result.success).toBe(false); - }); - }); - - 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(file); - } - }); - }); - - describe("array", () => { - 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); - if (result.success) { - expect(result.output).toEqual(["a", "b", "c"]); - } - }); - - it("should work with form data coercion", () => { - const schema = vf.array(vf.string()); - const result = v.safeParse(schema, "single-value"); - expect(result.success).toBe(true); - if (result.success) { - expect(result.output).toEqual(["single-value"]); - } - }); - }); -});