Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ jobs:
- 'bun.lock'
- '**/tsconfig*.json'
- '**/*.ts'
- '**/*.tsx'

lint-format:
needs: [setup]
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions examples/00-basic.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | File>.
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);
51 changes: 51 additions & 0 deletions examples/01-file-upload.ts
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions examples/02-field-presence.ts
Original file line number Diff line number Diff line change
@@ -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();
42 changes: 42 additions & 0 deletions examples/03-error-handling.ts
Original file line number Diff line number Diff line change
@@ -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");
40 changes: 40 additions & 0 deletions examples/04-integration-fetch.ts
Original file line number Diff line number Diff line change
@@ -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"));
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
}
},
"#safe-formdata": "./src/index.ts"
},
"type": "module",
"main": "./dist/index.js",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions tsconfig.examples.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"declaration": false,
"declarationMap": false
},
"include": ["examples/**/*.ts"]
}