diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adfdc8d..1094ca8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,6 @@ jobs: - 'bun.lock' - '**/tsconfig*.json' - '**/*.ts' - - '**/*.tsx' lint-format: needs: [setup] diff --git a/CLAUDE.md b/CLAUDE.md index 7e849cc..2e967eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ bun run test:coverage # Run tests with coverage report ```bash bun run build # Build for production -bun run check:type # TypeScript type checking +bun run check:type:source # TypeScript type checking ``` ## Architecture Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06238e0..c0264da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ Run these commands before submitting: ```bash bun run check:source # Linting and formatting must pass -bun run check:type # TypeScript type checking must pass +bun run check:type:source # TypeScript type checking must pass bun run test # All tests must pass bun run build # Build must succeed ``` @@ -123,8 +123,8 @@ Before submitting a PR: - [ ] Confirmed change does not violate the boundary - [ ] Added tests for new functionality - [ ] Linting and formatting passes (`bun run check:source`) +- [ ] Type checking passes (`bun run check:type:source`) - [ ] All tests pass (`bun run test`) -- [ ] Type checking passes (`bun run check:type`) - [ ] Build succeeds (`bun run build`) ### PR Description diff --git a/examples/00-basic.ts b/examples/00-basic.ts new file mode 100644 index 0000000..c14c7e0 --- /dev/null +++ b/examples/00-basic.ts @@ -0,0 +1,38 @@ +import { parse } from "#safe-formdata"; + +/** + * Minimal example + * - basic fields + * - validation guard + * - explicit type narrowing for FormData values + */ + +const formData = new FormData(); +formData.set("name", "Alice"); +formData.set("age", "20"); + +const result = parse(formData); + +if (result.data === null) { + // Validation failed + console.error(result.issues); + throw new Error("Invalid FormData"); +} + +// From here on, result.data is non-null, +// but it is typed as Record. +const data = result.data; + +// name: string | File +const name = data["name"]; +if (typeof name !== "string") { + throw new Error("Expected name to be a string"); +} +name.toUpperCase(); + +// age: string | File +const age = data["age"]; +if (typeof age !== "string") { + throw new Error("Expected age to be a string"); +} +Number(age).toFixed(0); diff --git a/examples/01-file-upload.ts b/examples/01-file-upload.ts new file mode 100644 index 0000000..7da069b --- /dev/null +++ b/examples/01-file-upload.ts @@ -0,0 +1,51 @@ +import { parse } from "#safe-formdata"; + +/** + * File upload example + * - handling File values from FormData + * - explicit type narrowing with instanceof File + * - size and type checks + */ + +const formData = new FormData(); + +// In real usage, this usually comes from a multipart/form-data request +formData.set( + "avatar", + new File(["dummy image data"], "avatar.png", { + type: "image/png", + }), +); + +const result = parse(formData); + +if (result.data === null) { + // Validation failed + console.error(result.issues); + throw new Error("Invalid FormData"); +} + +// From here on, result.data is non-null, +// but values are still typed as string | File. +const data = result.data; + +// avatar: string | File +const avatar = data["avatar"]; + +// Narrow to File before accessing file-specific properties +if (!(avatar instanceof File)) { + throw new Error("Expected avatar to be a File"); +} + +// File-specific checks +const maxSizeInBytes = 2 * 1024 * 1024; // 2MB + +if (avatar.size > maxSizeInBytes) { + throw new Error("File is too large"); +} + +if (avatar.type !== "image/png") { + throw new Error("Unsupported file type"); +} + +// Safe to use as File here diff --git a/examples/02-field-presence.ts b/examples/02-field-presence.ts new file mode 100644 index 0000000..43f9042 --- /dev/null +++ b/examples/02-field-presence.ts @@ -0,0 +1,47 @@ +import { parse } from "#safe-formdata"; + +/** + * Field presence example + * - fields may or may not exist + * - there is no "optional" field + * - presence must be checked explicitly + */ + +const formData = new FormData(); + +// Simulate a partially filled form +formData.set("username", "alice"); + +const result = parse(formData); + +if (result.data === null) { + console.error(result.issues); + throw new Error("Invalid FormData"); +} + +// result.data is non-null, +// but keys are not guaranteed to exist. +const data = result.data; + +// Check field presence explicitly +if (!("email" in data)) { + // The field was not sent at all + console.log("email was not provided"); +} else { + const email = data["email"]; + + // Even if present, the type is still string | File + if (typeof email !== "string") { + throw new Error("Expected email to be a string"); + } + + email.toLowerCase(); +} + +// username is present +const username = data["username"]; +if (typeof username !== "string") { + throw new Error("Expected username to be a string"); +} + +username.toUpperCase(); diff --git a/examples/03-error-handling.ts b/examples/03-error-handling.ts new file mode 100644 index 0000000..664f185 --- /dev/null +++ b/examples/03-error-handling.ts @@ -0,0 +1,42 @@ +import { parse } from "#safe-formdata"; + +/** + * Error handling example + * - how to inspect validation issues + * - data is null when validation fails + * - duplicated keys are rejected + */ + +const formData = new FormData(); + +// This intentionally creates an invalid FormData. +// safe-formdata does NOT allow duplicated keys. +formData.append("role", "admin"); +formData.append("role", "user"); + +const result = parse(formData); + +if (result.data !== null) { + // This should never happen in this example + throw new Error("Expected validation to fail"); +} + +// data is null when validation fails +console.log("Validation failed"); + +// issues contains detailed information about what went wrong +for (const issue of result.issues) { + // Issues are structured data. + // Interpret or format them at a higher layer if needed. + console.error({ + code: issue.code, + path: issue.path, + key: issue.key, + }); +} + +// Typical handling pattern: +// - log issues +// - return HTTP 400 +// - show a validation error to the user +throw new Error("Invalid FormData"); diff --git a/examples/04-integration-fetch.ts b/examples/04-integration-fetch.ts new file mode 100644 index 0000000..87596f6 --- /dev/null +++ b/examples/04-integration-fetch.ts @@ -0,0 +1,40 @@ +import { parse } from "#safe-formdata"; + +/** + * Integration example with Fetch / Request + * - how to use safe-formdata in a real request handler + * - parse FormData from Request + * - handle missing / invalid fields + */ + +async function handleRequest(request: Request) { + // Parse incoming FormData + const formData = await request.formData(); + + const result = parse(formData); + + if (result.data === null) { + // Validation failed + console.error("Validation issues:", result.issues); + return new Response("Invalid FormData", { status: 400 }); + } + + // result.data is non-null here + const data = result.data; + + // Example: retrieve 'username' field safely + if (!("username" in data)) { + return new Response("Username is required", { status: 400 }); + } + + const username = data["username"]; + if (typeof username !== "string") { + return new Response("Username must be a string", { status: 400 }); + } + + // Proceed with valid data + return new Response(`Hello, ${username.toUpperCase()}`, { status: 200 }); +} + +// Dummy call to satisfy lint / tsc +void handleRequest(new Request("https://example.com")); diff --git a/package.json b/package.json index 0f4ed32..09144f5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "types": "./src/*.ts", "import": "./src/*.ts", "default": "./src/*.ts" - } + }, + "#safe-formdata": "./src/index.ts" }, "type": "module", "main": "./dist/index.js", @@ -36,7 +37,6 @@ "dev": "tsup --watch", "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix-suggestions", - "check:type": "tsc --noEmit", "format:biome": "biome check --write --assist-enabled true", "format:prettier": "prettier --cache --write \"**/*.{md,yml,yaml}\"", "format": "npm-run-all2 format:biome format:prettier", @@ -46,6 +46,9 @@ "check:format": "npm-run-all2 check:biome check:prettier", "check:source": "npm-run-all2 lint check:format", "check": "bun run check:source", + "check:type:source": "tsc --noEmit", + "check:type:example": "tsc --project tsconfig.examples.json --noEmit", + "check:type": "npm-run-all2 check:type:source check:type:example", "check:prettier:ci": "prettier --check \"**/*.{md,yml,yaml}\"", "check:format:ci": "npm-run-all2 check:biome check:prettier:ci", "check:source:ci": "npm-run-all2 lint check:format:ci", @@ -54,7 +57,7 @@ "test:coverage": "vitest run --coverage", "build": "tsup", "check:package": "publint && attw --pack . --ignore-rules cjs-resolves-to-esm", - "prepublishOnly": "bun run check:type && bun run test:coverage && bun run build && bun run check:package" + "prepublishOnly": "bun run check:type:source && bun run test:coverage && bun run build && bun run check:package" }, "devDependencies": { "@arethetypeswrong/cli": "0.18.2", diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..570b9b7 --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false + }, + "include": ["examples/**/*.ts"] +}