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 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) 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)); +``` 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 diff --git a/AGENTS.md b/AGENTS.md index 056e5aa..f0a33a8 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`, `picklist`, `file`, `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 12a5581..d7fca64 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"), @@ -70,16 +70,26 @@ 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 - **[`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/package-lock.json b/package-lock.json index 37893f6..35864b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,18 @@ "@types/node": "^24.5.2", "prettier": "^3.6.2", "typescript": "^5.9.2", - "vitest": "^3.2.4" + "valibot": "^1.1.0", + "vitest": "^3.2.4", + "zod": "^4.1.11" }, "peerDependencies": { + "valibot": "^1.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "valibot": { + "optional": true + }, "zod": { "optional": true } @@ -2720,6 +2726,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", @@ -2943,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 7d8357a..fecfb3b 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", @@ -51,12 +46,18 @@ "@types/node": "^24.5.2", "prettier": "^3.6.2", "typescript": "^5.9.2", - "vitest": "^3.2.4" + "valibot": "^1.1.0", + "vitest": "^3.2.4", + "zod": "^4.1.11" }, "peerDependencies": { + "valibot": "^1.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "valibot": { + "optional": true + }, "zod": { "optional": true } 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 diff --git a/src/coerce.ts b/src/coerce.ts new file mode 100644 index 0000000..163ced8 --- /dev/null +++ b/src/coerce.ts @@ -0,0 +1,81 @@ +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 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/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"; 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/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/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..ab9fd1d --- /dev/null +++ b/src/valibot/schemas.ts @@ -0,0 +1,64 @@ +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/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. diff --git a/src/zod/schemas.ts b/src/zod/schemas.ts index 53e2c15..aafddaa 100644 --- a/src/zod/schemas.ts +++ b/src/zod/schemas.ts @@ -1,140 +1,91 @@ import * as z from "zod"; +import { + coerceString, + coerceNumber, + coerceBigint, + coerceBoolean, + coerceDate, + coerceFile, + 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((v) => { - if (typeof v !== "string") { - return v; - } - if (v === "") { - return undefined; - } - return v; - }, z.string(params)); + 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((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)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ 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)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ 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)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ 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)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ 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(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((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)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ 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)); } +/** + * @deprecated The Zod utilities will be removed in the next major release. + */ 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)); } +/** + * @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], ) { - 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)); } 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]); + }); + }); +}); 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" }, }); }); diff --git a/test/valibot/schemas.test.ts b/test/valibot/schemas.test.ts new file mode 100644 index 0000000..ca8d8dc --- /dev/null +++ b/test/valibot/schemas.test.ts @@ -0,0 +1,170 @@ +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"]); + } + }); + }); +}); diff --git a/test/zod/schemas.test.ts b/test/zod/schemas.test.ts index 397f13d..93d8e73 100644 --- a/test/zod/schemas.test.ts +++ b/test/zod/schemas.test.ts @@ -1,23 +1,9 @@ import { describe, it, expect } from "vitest"; import * as zf from "../../src/zod/schemas.js"; -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 +11,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 = zf.string(); + const result = schema.safeParse(true); 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 +35,13 @@ describe("zod schemas preprocessing", () => { } }); - it("should convert string numbers to numbers", () => { + it("should handle validation errors", () => { + const schema = zf.number(); + const result = schema.safeParse("hello"); + 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 +51,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 = zf.bigint(); + const result = schema.safeParse("hello"); 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 +108,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", () => { + it("should handle validation errors", () => { const schema = zf.enum_(["a", "b", "c"]); - const result = schema.safeParse(""); + const result = schema.safeParse("d"); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); - }); - - it("should pass through non-string values unchanged", () => { - const schema = zf.enum_(["a", "b", "c"]); - const result = schema.safeParse(123); - 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 +156,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 +173,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 +191,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"]); } }); });