From 06eb7cf4c3a27f4b5a6fac25bcc1633394f7c652 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 21:32:09 +0200 Subject: [PATCH 1/6] Refactor valibot coercion pipes --- AGENTS.md | 4 +- README.md | 4 +- src/valibot/README.md | 25 ++-- src/valibot/coerce.ts | 42 ++++++ src/valibot/index.ts | 17 ++- src/valibot/schemas.ts | 64 --------- test/valibot/coerce.test.ts | 247 +++++++++++++++++++++++++++++++++++ test/valibot/schemas.test.ts | 170 ------------------------ 8 files changed, 314 insertions(+), 259 deletions(-) create mode 100644 src/valibot/coerce.ts delete mode 100644 src/valibot/schemas.ts create mode 100644 test/valibot/coerce.test.ts delete mode 100644 test/valibot/schemas.test.ts 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/valibot/README.md b/src/valibot/README.md index 605cf25..b960941 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 `FormDataEntryValue` to clean 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/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"]); - } - }); - }); -}); From 0a977d7b1f292be40756634bac44a1f0158c8fe3 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 21:46:19 +0200 Subject: [PATCH 2/6] Update readme --- src/valibot/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/valibot/README.md b/src/valibot/README.md index b960941..3410745 100644 --- a/src/valibot/README.md +++ b/src/valibot/README.md @@ -6,7 +6,7 @@ The Valibot Utilities are provided under the `conformal/valibot` subpath. Valibo ## Coercion Pipes -These coercion pipes handle the conversion from `FormDataEntryValue` to clean 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()`. +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"; From 6edc1d9ca8311bea338453b366e5fb6d3c30224c Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 21:50:20 +0200 Subject: [PATCH 3/6] Add changeset --- .changeset/gold-numbers-tap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gold-numbers-tap.md 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. From da8c457ce172375c9c11e6116d20845971eb5955 Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 23:25:22 +0200 Subject: [PATCH 4/6] align coerceNumber invalid input handling --- src/README.md | 5 +++-- src/coerce.ts | 3 ++- test/coerce.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) 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/test/coerce.test.ts b/test/coerce.test.ts index e8066fe..403a71e 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 bigint strings", () => { + expect(coerceNumber("abc")).toBe("abc"); + expect(coerceNumber("12.34.56")).toBe("12.34.56"); }); }); From 485448f07828178686aef9fd1afe55fbf1c8327f Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 23:26:23 +0200 Subject: [PATCH 5/6] Add changeset --- .changeset/all-mangos-smell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/all-mangos-smell.md 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 From 0e4c534817a57e72173b3f0e430f5a801d1528bb Mon Sep 17 00:00:00 2001 From: Marco Muser Date: Sun, 28 Sep 2025 23:30:12 +0200 Subject: [PATCH 6/6] fix test title --- test/coerce.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/coerce.test.ts b/test/coerce.test.ts index 403a71e..6fd1b0d 100644 --- a/test/coerce.test.ts +++ b/test/coerce.test.ts @@ -47,7 +47,7 @@ describe("coerce functions", () => { expect(coerceNumber("-10")).toBe(-10); }); - it("should return original string for invalid bigint strings", () => { + it("should return original string for invalid number strings", () => { expect(coerceNumber("abc")).toBe("abc"); expect(coerceNumber("12.34.56")).toBe("12.34.56"); });