diff --git a/AGENTS.md b/AGENTS.md index a892e79..056e5aa 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`; types: `PathsFromObject`, `Submission` -- `conformal/zod`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email` +- `conformal/zod`: `string`, `number`, `boolean`, `date`, `bigint`, `enum`, `file`, `url`, `email`, `object`, `array` Exports live in `src/index.ts` and `src/zod/index.ts`. @@ -43,7 +43,7 @@ Exports live in `src/index.ts` and `src/zod/index.ts`. ```bash # Fast local CI -npm run format:check && npm run typecheck && npm run test && npm run build +npm run format:check && npm run typecheck && npm run test # Focus tests npx vitest run -t "" # by test name diff --git a/README.md b/README.md index 6628e93..1f35c0a 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,21 @@ > Type-safe form submissions for the modern web. -Conformal helps you work with native [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData) the way frameworks are moving: directly. It solves two major pain points: +Conformal helps you work with native [`FormData`](https://developer.mozilla.org/docs/Web/API/FormData). It solves two major pain points: - ✅ **Strongly typed FormData parsing** – Turn native `FormData` into real objects with full TypeScript inference (nested objects and arrays with dot/bracket notation). - ✅ **Canonical submission flow** – A single `Submission` object that preserves raw input, separates field vs. form errors, and standardizes the success/error states. -Works everywhere: In browsers, Node.js, and edge runtimes with React, Vue, Svelte, or vanilla JavaScript. No framework lock-in. +Works everywhere: In browsers, Node.js, and edge runtimes with React, Vue, Svelte, or vanilla JavaScript. ### Table of Contents -- [Installation](#installation) +- [Getting Started](#getting-started) - [Live Examples](#live-examples) -- [Usage](#usage) - - [parseFormData](#parseformdata) - - [Submission](#submission) - - [decode](#decode) - - [serialize](#serialize) - - [getPath](#getpath) - - [setPath](#setpath) - - [PathsFromObject](#pathsfromobject) -- [Zod Field Schemas](#zod-field-schemas) +- [API Reference](#api-reference) - [License](#license) -## Installation +## Getting Started Install Conformal via npm or the package manager of your choice: @@ -32,232 +24,60 @@ Install Conformal via npm or the package manager of your choice: npm install conformal ``` -## Live Examples - -- **React** - Form actions with useActionState: [StackBlitz](https://stackblitz.com/github/marcomuser/conformal/tree/main/examples/react?embed=1&theme=dark&preset=node&file=src/Form.tsx) | [Source](https://github.com/marcomuser/conformal/tree/main/examples/react) - -- **SvelteKit** - Server-side form actions: [StackBlitz](https://stackblitz.com/github/marcomuser/conformal/tree/main/examples/svelte?embed=1&theme=dark&preset=node&file=src/routes/%2Bpage.server.ts) | [Source](https://github.com/marcomuser/conformal/tree/main/examples/svelte) - -## Usage - -### parseFormData - -The `parseFormData` function parses and validates [FormData](https://developer.mozilla.org/docs/Web/API/FormData) against a [Standard Schema](https://standardschema.dev). It internally uses the [decode](#decode) function to first convert the `FormData` into a structured object before applying schema validation. - -**🚀 Try it yourself**: This example includes an import map and can be run directly in a browser! - -```html - -
- - - - - -
- - - - - -``` - -This will result in the following data structure: +Here's a quick example showing how Conformal handles form validation with a user registration form: ```typescript -const value = { - name: "John Doe", - age: 30, - hobbies: ["Music", "Coding"], -}; -``` - -The `parseFormData` function returns a `SchemaResult` object that extends the standard schema validation result with a `submission()` method. This method provides a consistent `Submission` object that makes it easy to handle both successful and failed validation results: +import { parseFormData } from "conformal"; +import * as z from "zod"; // Tip: Use conformal/zod for automatic form input preprocessing + +const schema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters"), + email: z.email("Invalid email address"), + age: z.coerce.number().min(18, "Must be at least 18 years old"), + acceptTerms: z.coerce.boolean(), +}); -```typescript -const submission = parseFormData(schema, formData).submission(); +// In your form action or handler +const result = parseFormData(schema, formData); +const submission = result.submission(); if (submission.status === "success") { - // Access validated data - const validatedData = submission.value; - // Access raw parsed form data - const rawInput = submission.input; + // submission.value is fully typed: { name: string, email: string, age: number, acceptTerms: boolean } + console.log("User registered:", submission.value); } else { - // Handle validation errors - const fieldErrors = submission.fieldErrors; // Field-specific errors - const formErrors = submission.formErrors; // Form-level errors - // Access raw parsed form data even on failure - const rawInput = submission.input; + // submission.fieldErrors contains validation errors: { email: ["Invalid email address"] } + console.log("Validation errors:", submission.fieldErrors); + // submission.input preserves the raw user input for re-display + console.log("User input:", submission.input); } ``` -### Submission - -The `Submission` type represents the result of form validation and provides a clean interface for handling both successful and failed validation results. This is the type that the `submission()` method returns from `parseFormData`. - -**Properties:** - -- **`status`**: A string that tells you the outcome - either `"success"` when validation passes, `"error"` when it fails, or `"idle"` for initial states -- **`value`**: Contains your validated and typed data when `status` is `"success"`. This is `undefined` when there are validation errors -- **`input`**: Always contains the raw user input that was submitted, regardless of validation success or failure. This is useful for preserving user input even when validation fails -- **`fieldErrors`**: An object that maps field names to arrays of error messages. For example, `{ "email": ["Invalid email format"], "age": ["Must be a number"] }`. This is empty when validation succeeds -- **`formErrors`**: An array of form-level validation errors that aren't tied to specific fields. For example, `["Passwords don't match", "Terms must be accepted"]`. This is empty when validation succeeds +That's it! Conformal automatically handles FormData parsing, type coercion, and provides a clean submission interface. -**Key Benefits:** - -- **Type Safety**: Full TypeScript support with automatic type inference for your data -- **Data Preservation**: Raw input is always available, even on validation failure -- **Granular Error Handling**: Separate field and form-level errors for precise UI feedback -- **Immutable**: All properties are read-only, preventing accidental mutations - -### decode - -The `decode` function allows you to convert a `FormData` object into a structured object with typed values. It supports both dot notation for nested objects and square bracket notation for arrays. You can mix dot and square bracket notation to create complex structures. The `decode` function allows you to create your own schema validator in cases where `parseFormData` does not support your use case. - -```typescript -import { decode } from "conformal"; - -const formData = new FormData(); -formData.append("user.name", "John Doe"); -formData.append("user.age", "30"); -formData.append("user.contacts[0].type", "email"); -formData.append("user.contacts[0].value", "john.doe@example.com"); -formData.append("user.contacts[1].type", "phone"); -formData.append("user.contacts[1].value", "123-456-7890"); - -const result = decode<{ - user: { - name: string; - age: string; - contacts: { type: string; value: string }[]; - }; -}>(formData); -``` - -This will result in the following data structure: - -```typescript -const result = { - user: { - name: "John Doe", - age: "30", - contacts: [ - { type: "email", value: "john.doe@example.com" }, - { type: "phone", value: "123-456-7890" }, - ], - }, -}; -``` - -### serialize - -The `serialize` function transforms fully typed values back to the InputValue shape for use in form elements. This is particularly useful for setting default values in form fields when you have validated data from a previous submission and want to pre-fill forms with existing data from a database. - -```typescript -import { serialize } from "conformal"; - -console.log(serialize(123)); // "123" -console.log(serialize(true)); // "on" -console.log(serialize(new Date())); // "2025-01-17T17:04:25.059Z" -console.log(serialize({ username: "test", age: 100 })); // { username: "test", age: "100" } -``` - -### getPath - -Retrieve a value from an object using a path. This function is a foundational tool for handling object paths using dot and square bracket notation. It's particularly useful for developers building custom client-side validation libraries or complex data manipulation patterns. - -```typescript -import { getPath } from "conformal"; - -const value = getPath({ a: { b: { c: ["hey", "Hi!"] } } }, "a.b.c[1]"); -// Returns 'Hi!' -``` - -### setPath - -Set a value in an object using a path. The `setPath` function is used internally by the `decode` function and provides powerful object manipulation capabilities. **Note**: Creates copies only where needed to preserve immutability, avoiding unnecessary deep copying for better performance. - -```typescript -import { setPath } from "conformal"; - -const newObj = setPath({ a: { b: { c: [] } } }, "a.b.c[1]", "hey"); -// Returns { a: { b: { c: [, 'hey'] } } } -``` - -### PathsFromObject +## Live Examples -Extract all possible paths from an object type while automatically excluding paths that lead to browser-specific built-in types such as Blob, FileList, and Date. This type utility is useful for creating abstractions that enable type-safe access to specific fields within complex form data structures. +- **React** - Form actions with useActionState: [StackBlitz](https://stackblitz.com/github/marcomuser/conformal/tree/main/examples/react?embed=1&theme=dark&preset=node&file=src/Form.tsx) | [Source](https://github.com/marcomuser/conformal/tree/main/examples/react) -```typescript -import type { PathsFromObject } from "conformal"; +- **SvelteKit** - Server-side form actions: [StackBlitz](https://stackblitz.com/github/marcomuser/conformal/tree/main/examples/svelte?embed=1&theme=dark&preset=node&file=src/routes/%2Bpage.server.ts) | [Source](https://github.com/marcomuser/conformal/tree/main/examples/svelte) -interface UserForm { - user: { - name: string; - profilePicture: File; - contacts: { type: string; value: string }[]; - }; -} +## API Reference -type Paths = PathsFromObject; -// Paths will be "user" | "user.name" | "user.profilePicture" | "user.contacts" | `user.contacts[${number}]` | `user.contacts[${number}].type` | `user.contacts[${number}].value` -``` +### Core Functions -## Zod Field Schemas +- **[`parseFormData`](src/README.md#parseformdata)** - Parse FormData with schema validation and get Submission object +- **[`decode`](src/README.md#decode)** - Convert FormData to structured objects (no validation) +- **[`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 -Conformal provides optional Zod utilities that are **thin preprocessing wrappers** around Zod schemas. They automatically handle form input patterns (empty strings, type coercion, boolean detection) while maintaining **100% Zod compatibility**. +### Types -**Zero learning curve** - use them exactly like regular Zod schemas with all methods (`.optional()`, `.min()`, `.max()` etc.). Import from `conformal/zod` to keep your bundle lean if you don't use Zod. +- **[`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 -```typescript -import * as zf from "conformal/zod"; +### Zod Utilities -const formSchema = zf.object({ - name: zf.string().optional(), - email: zf.email(), - age: zf.number().min(13, "Must be at least 13 years old"), - hobbies: zf.array(zf.string()), - birthDate: zf.date(), - acceptTerms: zf.boolean(), - profilePicture: zf.file(), - accountType: zf.enum(["personal", "business"]), - website: zf.url().optional(), - transactionAmount: zf.bigint(), -}); -``` +- **[Zod Field Schemas](src/zod/README.md#field-schemas)** - Zod schemas with automatic form input preprocessing ## License diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..24da610 --- /dev/null +++ b/src/README.md @@ -0,0 +1,155 @@ +# Core API + +### Table of Contents + +- [Functions](#functions) + - [parseFormData](#parseformdata) + - [decode](#decode) + - [serialize](#serialize) + - [getPath](#getpath) + - [setPath](#setpath) +- [Types](#types) + - [Submission](#submission) + - [PathsFromObject](#pathsfromobject) + +## Functions + +### parseFormData + +The `parseFormData` function parses and validates [FormData](https://developer.mozilla.org/docs/Web/API/FormData) against a [Standard Schema](https://standardschema.dev). It internally uses the [decode](#decode) function to first convert the `FormData` into a structured object before applying schema validation. + +```typescript +import { parseFormData } from "conformal"; + +const result = parseFormData(schema, formData); +const submission = result.submission(); + +if (submission.status === "success") { + console.log(submission.value); // Validated data +} else { + console.log(submission.fieldErrors); // Validation errors +} +``` + +The `parseFormData` function returns the Standard Schema `Result` extended with a `submission()` method. This method returns a consistent [Submission](#submission) object that makes it easy to handle both successful and failed validation results. + +### decode + +The `decode` function allows you to convert a `FormData` object into a structured object with typed values. It supports both dot notation for nested objects and square bracket notation for arrays. You can mix dot and square bracket notation to create complex structures. The `decode` function allows you to create your own schema validator in cases where `parseFormData` does not support your use case. + +```typescript +import { decode } from "conformal"; + +const formData = new FormData(); +formData.append("user.name", "John Doe"); +formData.append("user.age", "30"); +formData.append("user.contacts[0].type", "email"); +formData.append("user.contacts[0].value", "john.doe@example.com"); +formData.append("user.contacts[1].type", "phone"); +formData.append("user.contacts[1].value", "123-456-7890"); + +const result = decode<{ + user: { + name: string; + age: string; + contacts: { type: string; value: string }[]; + }; +}>(formData); +``` + +This will result in the following data structure: + +```typescript +const result = { + user: { + name: "John Doe", + age: "30", + contacts: [ + { type: "email", value: "john.doe@example.com" }, + { type: "phone", value: "123-456-7890" }, + ], + }, +}; +``` + +### serialize + +The `serialize` function transforms fully typed values back to the `InputValue` shape for use in form elements. This is particularly useful for setting default values in form fields when you have validated data from a previous submission and want to pre-fill forms with existing data from a database. + +```typescript +import { serialize } from "conformal"; + +console.log(serialize(123)); // "123" +console.log(serialize(true)); // "on" +console.log(serialize(new Date())); // "2025-01-17T17:04:25.059Z" +console.log(serialize({ username: "test", age: 100 })); // { username: "test", age: "100" } +``` + +### getPath + +Retrieve a value from an object using a path. This function is a foundational tool for handling object paths using dot and square bracket notation. It's particularly useful for developers building custom client-side validation libraries or complex data manipulation patterns. + +```typescript +import { getPath } from "conformal"; + +const value = getPath({ a: { b: { c: ["hey", "Hi!"] } } }, "a.b.c[1]"); +// Returns 'Hi!' +``` + +### setPath + +Set a value in an object using a path. The `setPath` function is used internally by the `decode` function and provides powerful object manipulation capabilities. **Note**: Creates copies only where needed to preserve immutability, avoiding unnecessary deep copying for better performance. + +```typescript +import { setPath } from "conformal"; + +const newObj = setPath({ a: { b: { c: [] } } }, "a.b.c[1]", "hey"); +// Returns { a: { b: { c: [, 'hey'] } } } +``` + +## Types + +### Submission + +The `Submission` type represents the result of form validation and provides a clean interface for handling both successful and failed validation results. This is the shape that the `submission()` method returns: + +- **`status`**: A string that tells you the outcome - either `"success"` when validation passes, `"error"` when it fails, or `"idle"` for initial states +- **`value`**: Contains your validated and typed data when `status` is `"success"`. This is `undefined` when there are validation errors +- **`input`**: Always contains the raw user input that was submitted, regardless of validation success or failure. This is useful for preserving user input even when validation fails +- **`fieldErrors`**: An object that maps field names to arrays of error messages. For example, `{ "email": ["Invalid email format"], "age": ["Must be a number"] }`. This is empty when validation succeeds +- **`formErrors`**: An array of form-level validation errors that aren't tied to specific fields. For example, `["Passwords don't match", "Terms must be accepted"]`. This is empty when validation succeeds + +```typescript +const submission = parseFormData(schema, formData).submission(); + +if (submission.status === "success") { + // Use validated data + console.log("User created:", submission.value); + // submission.value is fully typed based on your schema +} else { + // Handle validation errors + console.log("Field errors:", submission.fieldErrors); + console.log("Form errors:", submission.formErrors); + // submission.input contains the raw user input for re-display + console.log("User input:", submission.input); +} +``` + +### PathsFromObject + +Extract all possible paths from an object type while automatically excluding paths that lead to browser-specific built-in types such as Blob, FileList, and Date. This type utility is useful for creating abstractions that enable type-safe access to specific fields within complex form data structures. + +```typescript +import type { PathsFromObject } from "conformal"; + +interface UserForm { + user: { + name: string; + profilePicture: File; + contacts: { type: string; value: string }[]; + }; +} + +type Paths = PathsFromObject; +// Paths will be "user" | "user.name" | "user.profilePicture" | "user.contacts" | `user.contacts[${number}]` | `user.contacts[${number}].type` | `user.contacts[${number}].value` +``` diff --git a/src/zod/README.md b/src/zod/README.md new file mode 100644 index 0000000..dd0cade --- /dev/null +++ b/src/zod/README.md @@ -0,0 +1,24 @@ +# Zod Utilities + +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. + +## 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 Zod and can be mixed with regular Zod schemas. + +```typescript +import * as zf from "conformal/zod"; + +const formSchema = zf.object({ + name: zf.string().optional(), + email: zf.email(), + age: zf.number().min(13, "Must be at least 13 years old"), + hobbies: zf.array(zf.string()), + birthDate: zf.date(), + acceptTerms: zf.boolean(), + profilePicture: zf.file(), + accountType: zf.enum(["personal", "business"]), + website: zf.url().optional(), + transactionAmount: zf.bigint(), +}); +```