Skip to content
Merged
6 changes: 2 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ No additional IssueCode may be introduced without a major version bump.
### ParseIssue shape

- `code` must be one of the allowed IssueCode values.
- `path` must always be an empty array (no structural inference). This field exists only to preserve compatibility with external issue formats.
- `key?` may contain the problematic key when an issue occurs (for debugging purposes).
- `key` must be the original FormData key that caused the issue, reported as-is without interpretation.
- Issues are informational, not exceptions.

Note: In v1.0+, additional fields such as `message: string` and `meta?: Record<string, unknown>` may be considered for enhanced error reporting.
Expand Down Expand Up @@ -170,8 +169,7 @@ if (result.data !== null) {
```ts
export interface ParseIssue {
code: IssueCode;
path: readonly [];
key?: unknown;
key: string;
}
```

Expand Down
59 changes: 57 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,62 @@

This project uses GitHub Releases as the single source of truth for all changes.

For the full and authoritative change history, including breaking changes and migration notes,
For the full and authoritative change history, including breaking changes and migration notes,
please see: <https://github.com/roottool/safe-formdata/releases>

No additional changelog is maintained in this file.
Migration guides for breaking changes are maintained below.

---

## [Unreleased]

> **Breaking changes** — In the 0.x series, minor version bumps are treated as effectively major releases.

### `ParseIssue.path` removed

The `path` field has been removed from `ParseIssue`.

```ts
// v0.1.x
interface ParseIssue {
code: IssueCode;
path: readonly []; // removed
}

// v0.2.0
interface ParseIssue {
code: IssueCode;
key: string; // added (see below)
}
```

**Migration**: Remove all references to `issue.path`. Because `path` was always an empty array, any reference to it was effectively a no-op and can be deleted outright.

### `ParseIssue.key` added (required, `string`)

A required `key: string` field has been added to identify which FormData key caused the issue.

```ts
// v0.1.x — no key field
issue.code; // "forbidden_key"

// v0.2.0 — key identifies the offending field
issue.code; // "forbidden_key"
issue.key; // "__proto__"
```

**Migration**: Use `issue.key` wherever you need to identify the offending field. This is an additive change with no runtime impact, but type definitions that reference `ParseIssue` must be updated.

### `issues` on failure narrowed to a non-empty tuple

```ts
// v0.1.x
issues: ParseIssue[]

// v0.2.0
issues: [ParseIssue, ...ParseIssue[]]
```

When parsing fails (`data === null`), the type now guarantees at least one issue is present.

**Migration**: Accessing `result.issues[0]` remains safe. No changes to narrowing logic are required. However, if you reference the `ParseResult` type explicitly, you may need to update type annotations.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,11 @@ export interface ParseResult {
```ts
export interface ParseIssue {
code: "invalid_key" | "forbidden_key" | "duplicate_key";
path: string[];
key?: unknown;
key: string;
}
```

- `path` is always empty and exists only for compatibility
- `key` is the original FormData key that caused the issue
- Issues are informational and are never thrown

---
Expand Down
1 change: 0 additions & 1 deletion examples/03-error-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ for (const issue of result.issues) {
// Interpret or format them at a higher layer if needed.
console.error({
code: issue.code,
path: issue.path,
key: issue.key,
});
}
Expand Down
2 changes: 0 additions & 2 deletions skills/boundary-validator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ data[key] = value; // always overwrites
if (seen.has(key)) {
issues.push({
code: "duplicate_key",
path: [],
key,
});
// Do NOT continue processing
Expand Down Expand Up @@ -221,7 +220,6 @@ const FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"];
if (FORBIDDEN_KEYS.includes(key)) {
issues.push({
code: "forbidden_key",
path: [],
key,
});
}
Expand Down
2 changes: 0 additions & 2 deletions skills/boundary-validator/examples/bad-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,6 @@ function parse(formData: FormData): ParseResult {
if (key === "__proto__") {
issues.push({
code: "forbidden_key",
path: [],
key,
});
// Problem: Continues processing other keys
Expand Down Expand Up @@ -422,7 +421,6 @@ function parse(formData: FormData): ParseResult {
if (typeof key !== "string" || key.length === 0) {
issues.push({
code: "invalid_key",
path: [],
key,
});
continue;
Expand Down
12 changes: 1 addition & 11 deletions skills/boundary-validator/examples/good-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function parse(formData: FormData): ParseResult {
if (typeof key !== "string" || key.length === 0) {
issues.push({
code: "invalid_key" as IssueCode,
path: [] as const,
key,
});
continue;
Expand All @@ -31,7 +30,6 @@ export function parse(formData: FormData): ParseResult {
if (FORBIDDEN_KEYS.includes(key as any)) {
issues.push({
code: "forbidden_key" as IssueCode,
path: [] as const,
key,
});
continue;
Expand All @@ -41,7 +39,6 @@ export function parse(formData: FormData): ParseResult {
if (seen.has(key)) {
issues.push({
code: "duplicate_key" as IssueCode,
path: [] as const,
key,
});
continue;
Expand Down Expand Up @@ -70,7 +67,6 @@ export function parse(formData: FormData): ParseResult {
if (typeof key !== "string") {
issues.push({
code: "invalid_key",
path: [],
key,
});
continue;
Expand All @@ -80,7 +76,6 @@ if (typeof key !== "string") {
if (key.length === 0) {
issues.push({
code: "invalid_key",
path: [],
key: "",
});
continue;
Expand All @@ -101,7 +96,6 @@ const FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"] as const;
if (FORBIDDEN_KEYS.includes(key as any)) {
issues.push({
code: "forbidden_key",
path: [],
key,
});
continue; // Do not process forbidden keys
Expand All @@ -127,7 +121,6 @@ for (const [key, value] of formData.entries()) {
if (seen.has(key)) {
issues.push({
code: "duplicate_key",
path: [],
key,
});
continue; // Do not process duplicate
Expand All @@ -137,7 +130,7 @@ for (const [key, value] of formData.entries()) {
}

// ✅ Alternative: Detect duplicates using Map
const keyCount = new Map<unknown, number>();
const keyCount = new Map<string, number>();

for (const [key] of formData.entries()) {
keyCount.set(key, (keyCount.get(key) || 0) + 1);
Expand All @@ -147,7 +140,6 @@ for (const [key, count] of keyCount) {
if (count > 1) {
issues.push({
code: "duplicate_key",
path: [],
key,
});
}
Expand Down Expand Up @@ -263,7 +255,6 @@ it("rejects __proto__ key", () => {
expect(result.data).toBeNull();
expect(result.issues).toContainEqual({
code: "forbidden_key",
path: [],
key: "__proto__",
});
});
Expand All @@ -279,7 +270,6 @@ it("reports duplicate keys", () => {
expect(result.data).toBeNull();
expect(result.issues).toContainEqual({
code: "duplicate_key",
path: [],
key: "username",
});
});
Expand Down
22 changes: 12 additions & 10 deletions skills/boundary-validator/references/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ These constraints ensure API stability and prevent breaking changes.

## Public API (Minimal and Stable)

The safe-formdata public API consists of a single function:
The safe-formdata public API consists of a single function and utility types:

```typescript
parse(formData: FormData): ParseResult

// Named utility types derived from ParseResult
type SuccessResult = Extract<ParseResult, { data: Record<string, string | File> }>;
type FailureResult = Extract<ParseResult, { data: null }>;
```

### Constraints
Expand Down Expand Up @@ -64,15 +68,15 @@ export type ParseResult =
}
| {
data: null;
issues: ParseIssue[];
issues: [ParseIssue, ...ParseIssue[]];
};
```

#### Constraints

- **Must be a discriminated union**: Two distinct shapes based on success/failure
- **Success state**: `data` is a Record, `issues` is an empty array (literal type `[]`)
- **Failure state**: `data` is `null`, `issues` is a non-empty array
- **Failure state**: `data` is `null`, `issues` is a non-empty tuple (`[ParseIssue, ...ParseIssue[]]`)
- **No intermediate states**: Partial success is not allowed

#### Type Narrowing Pattern
Expand Down Expand Up @@ -116,17 +120,14 @@ if (result.data !== null) {
```typescript
export interface ParseIssue {
code: IssueCode;
path: readonly [];
key?: unknown;
key: string;
}
```

#### Constraints

- **`code`**: Must be one of the allowed IssueCode values
- **`path`**: Must always be an empty array `[]` (no structural inference)
- This field exists only to preserve compatibility with external issue formats
- **`key`**: Optional, may contain the problematic key when an issue occurs (for debugging)
- **`key`**: Must be the original FormData key that caused the issue, reported as-is without interpretation
- **Issues are informational, not exceptions**

#### Future Considerations
Expand Down Expand Up @@ -230,7 +231,7 @@ See:
*/
export type ParseResult =
| { data: Record<string, string | File>; issues: [] }
| { data: null; issues: ParseIssue[] };
| { data: null; issues: [ParseIssue, ...ParseIssue[]] };
````

---
Expand Down Expand Up @@ -272,6 +273,7 @@ The following changes are **non-breaking** and allowed in minor/patch versions:
When reviewing API changes:

- [ ] Public API remains `parse(formData): ParseResult` only
- [ ] `SuccessResult` / `FailureResult` utility types are derived from `ParseResult` (not independently defined)
- [ ] No overloads added
- [ ] No options parameters added
- [ ] `ParseResult` structure unchanged
Expand All @@ -282,4 +284,4 @@ When reviewing API changes:
---

**Source**: AGENTS.md (lines 119-199)
**Last updated**: 2026-01-12
**Last updated**: 2026-03-02
2 changes: 0 additions & 2 deletions skills/boundary-validator/references/design-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ for (const [key, value] of formData.entries()) {
if (seen.has(key)) {
issues.push({
code: "duplicate_key",
path: [],
key,
});
continue; // Do not process further
Expand Down Expand Up @@ -216,7 +215,6 @@ for (const [key, value] of formData.entries()) {
if (typeof key !== "string" || key.length === 0) {
issues.push({
code: "invalid_key",
path: [],
key,
});
continue;
Expand Down
4 changes: 0 additions & 4 deletions skills/boundary-validator/references/security-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ for (const [key, value] of formData.entries()) {
if (FORBIDDEN_KEYS.includes(key as any)) {
issues.push({
code: "forbidden_key",
path: [],
key,
});
continue; // Do not process forbidden keys
Expand All @@ -70,7 +69,6 @@ const result = parse(formData);
expect(result.data).toBeNull();
expect(result.issues).toContainEqual({
code: "forbidden_key",
path: [],
key: "__proto__",
});

Expand All @@ -82,7 +80,6 @@ const result2 = parse(formData);
expect(result2.data).toBeNull();
expect(result2.issues).toContainEqual({
code: "forbidden_key",
path: [],
key: "constructor",
});

Expand All @@ -94,7 +91,6 @@ const result3 = parse(formData);
expect(result3.data).toBeNull();
expect(result3.issues).toContainEqual({
code: "forbidden_key",
path: [],
key: "prototype",
});
```
Expand Down
3 changes: 0 additions & 3 deletions skills/boundary-validator/references/validation-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ for (const [key, value] of formData.entries()) {
if (seen.has(key)) {
issues.push({
code: "duplicate_key",
path: [],
key,
});
continue; // Do not process
Expand Down Expand Up @@ -300,7 +299,6 @@ for (const [key, value] of formData.entries()) {
if (invalidCondition) {
issues.push({
code: "invalid_key",
path: [],
key,
});
continue; // Do not throw
Expand Down Expand Up @@ -362,7 +360,6 @@ for (const [key, value] of formData.entries()) {
if (FORBIDDEN_KEYS.includes(key as any)) {
issues.push({
code: "forbidden_key",
path: [],
key,
});
continue;
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ export { parse } from "#parse";
export type { IssueCode } from "#types/IssueCode";
export type { ParseIssue } from "#types/ParseIssue";
export type { ParseResult } from "#types/ParseResult";

import type { ParseResult } from "#types/ParseResult";

export type SuccessResult = Extract<ParseResult, { data: Record<string, string | File> }>;
export type FailureResult = Extract<ParseResult, { data: null }>;
Loading