Skip to content
This repository was archived by the owner on Mar 9, 2026. It is now read-only.

Commit dcdd746

Browse files
roottoolclaude
andauthored
feat!: v0.2.0 — reshape ParseIssue and strengthen ParseResult types (#59)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 07dffcc commit dcdd746

19 files changed

Lines changed: 115 additions & 127 deletions

AGENTS.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,7 @@ No additional IssueCode may be introduced without a major version bump.
119119
### ParseIssue shape
120120

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

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

CHANGELOG.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,62 @@
22

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

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

8-
No additional changelog is maintained in this file.
8+
Migration guides for breaking changes are maintained below.
9+
10+
---
11+
12+
## [Unreleased]
13+
14+
> **Breaking changes** — In the 0.x series, minor version bumps are treated as effectively major releases.
15+
16+
### `ParseIssue.path` removed
17+
18+
The `path` field has been removed from `ParseIssue`.
19+
20+
```ts
21+
// v0.1.x
22+
interface ParseIssue {
23+
code: IssueCode;
24+
path: readonly []; // removed
25+
}
26+
27+
// v0.2.0
28+
interface ParseIssue {
29+
code: IssueCode;
30+
key: string; // added (see below)
31+
}
32+
```
33+
34+
**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.
35+
36+
### `ParseIssue.key` added (required, `string`)
37+
38+
A required `key: string` field has been added to identify which FormData key caused the issue.
39+
40+
```ts
41+
// v0.1.x — no key field
42+
issue.code; // "forbidden_key"
43+
44+
// v0.2.0 — key identifies the offending field
45+
issue.code; // "forbidden_key"
46+
issue.key; // "__proto__"
47+
```
48+
49+
**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.
50+
51+
### `issues` on failure narrowed to a non-empty tuple
52+
53+
```ts
54+
// v0.1.x
55+
issues: ParseIssue[]
56+
57+
// v0.2.0
58+
issues: [ParseIssue, ...ParseIssue[]]
59+
```
60+
61+
When parsing fails (`data === null`), the type now guarantees at least one issue is present.
62+
63+
**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.

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,11 @@ export interface ParseResult {
239239
```ts
240240
export interface ParseIssue {
241241
code: "invalid_key" | "forbidden_key" | "duplicate_key";
242-
path: string[];
243-
key?: unknown;
242+
key: string;
244243
}
245244
```
246245

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

250249
---

examples/03-error-handling.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ for (const issue of result.issues) {
3030
// Interpret or format them at a higher layer if needed.
3131
console.error({
3232
code: issue.code,
33-
path: issue.path,
3433
key: issue.key,
3534
});
3635
}

skills/boundary-validator/SKILL.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,6 @@ data[key] = value; // always overwrites
124124
if (seen.has(key)) {
125125
issues.push({
126126
code: "duplicate_key",
127-
path: [],
128127
key,
129128
});
130129
// Do NOT continue processing
@@ -221,7 +220,6 @@ const FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"];
221220
if (FORBIDDEN_KEYS.includes(key)) {
222221
issues.push({
223222
code: "forbidden_key",
224-
path: [],
225223
key,
226224
});
227225
}

skills/boundary-validator/examples/bad-code.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,6 @@ function parse(formData: FormData): ParseResult {
350350
if (key === "__proto__") {
351351
issues.push({
352352
code: "forbidden_key",
353-
path: [],
354353
key,
355354
});
356355
// Problem: Continues processing other keys
@@ -422,7 +421,6 @@ function parse(formData: FormData): ParseResult {
422421
if (typeof key !== "string" || key.length === 0) {
423422
issues.push({
424423
code: "invalid_key",
425-
path: [],
426424
key,
427425
});
428426
continue;

skills/boundary-validator/examples/good-code.md

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export function parse(formData: FormData): ParseResult {
2121
if (typeof key !== "string" || key.length === 0) {
2222
issues.push({
2323
code: "invalid_key" as IssueCode,
24-
path: [] as const,
2524
key,
2625
});
2726
continue;
@@ -31,7 +30,6 @@ export function parse(formData: FormData): ParseResult {
3130
if (FORBIDDEN_KEYS.includes(key as any)) {
3231
issues.push({
3332
code: "forbidden_key" as IssueCode,
34-
path: [] as const,
3533
key,
3634
});
3735
continue;
@@ -41,7 +39,6 @@ export function parse(formData: FormData): ParseResult {
4139
if (seen.has(key)) {
4240
issues.push({
4341
code: "duplicate_key" as IssueCode,
44-
path: [] as const,
4542
key,
4643
});
4744
continue;
@@ -70,7 +67,6 @@ export function parse(formData: FormData): ParseResult {
7067
if (typeof key !== "string") {
7168
issues.push({
7269
code: "invalid_key",
73-
path: [],
7470
key,
7571
});
7672
continue;
@@ -80,7 +76,6 @@ if (typeof key !== "string") {
8076
if (key.length === 0) {
8177
issues.push({
8278
code: "invalid_key",
83-
path: [],
8479
key: "",
8580
});
8681
continue;
@@ -101,7 +96,6 @@ const FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"] as const;
10196
if (FORBIDDEN_KEYS.includes(key as any)) {
10297
issues.push({
10398
code: "forbidden_key",
104-
path: [],
10599
key,
106100
});
107101
continue; // Do not process forbidden keys
@@ -127,7 +121,6 @@ for (const [key, value] of formData.entries()) {
127121
if (seen.has(key)) {
128122
issues.push({
129123
code: "duplicate_key",
130-
path: [],
131124
key,
132125
});
133126
continue; // Do not process duplicate
@@ -137,7 +130,7 @@ for (const [key, value] of formData.entries()) {
137130
}
138131

139132
// ✅ Alternative: Detect duplicates using Map
140-
const keyCount = new Map<unknown, number>();
133+
const keyCount = new Map<string, number>();
141134

142135
for (const [key] of formData.entries()) {
143136
keyCount.set(key, (keyCount.get(key) || 0) + 1);
@@ -147,7 +140,6 @@ for (const [key, count] of keyCount) {
147140
if (count > 1) {
148141
issues.push({
149142
code: "duplicate_key",
150-
path: [],
151143
key,
152144
});
153145
}
@@ -263,7 +255,6 @@ it("rejects __proto__ key", () => {
263255
expect(result.data).toBeNull();
264256
expect(result.issues).toContainEqual({
265257
code: "forbidden_key",
266-
path: [],
267258
key: "__proto__",
268259
});
269260
});
@@ -279,7 +270,6 @@ it("reports duplicate keys", () => {
279270
expect(result.data).toBeNull();
280271
expect(result.issues).toContainEqual({
281272
code: "duplicate_key",
282-
path: [],
283273
key: "username",
284274
});
285275
});

skills/boundary-validator/references/api-contract.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ These constraints ensure API stability and prevent breaking changes.
88

99
## Public API (Minimal and Stable)
1010

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

1313
```typescript
1414
parse(formData: FormData): ParseResult
15+
16+
// Named utility types derived from ParseResult
17+
type SuccessResult = Extract<ParseResult, { data: Record<string, string | File> }>;
18+
type FailureResult = Extract<ParseResult, { data: null }>;
1519
```
1620

1721
### Constraints
@@ -64,15 +68,15 @@ export type ParseResult =
6468
}
6569
| {
6670
data: null;
67-
issues: ParseIssue[];
71+
issues: [ParseIssue, ...ParseIssue[]];
6872
};
6973
```
7074

7175
#### Constraints
7276

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

7882
#### Type Narrowing Pattern
@@ -116,17 +120,14 @@ if (result.data !== null) {
116120
```typescript
117121
export interface ParseIssue {
118122
code: IssueCode;
119-
path: readonly [];
120-
key?: unknown;
123+
key: string;
121124
}
122125
```
123126

124127
#### Constraints
125128

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

132133
#### Future Considerations
@@ -230,7 +231,7 @@ See:
230231
*/
231232
export type ParseResult =
232233
| { data: Record<string, string | File>; issues: [] }
233-
| { data: null; issues: ParseIssue[] };
234+
| { data: null; issues: [ParseIssue, ...ParseIssue[]] };
234235
````
235236

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

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

284286
**Source**: AGENTS.md (lines 119-199)
285-
**Last updated**: 2026-01-12
287+
**Last updated**: 2026-03-02

skills/boundary-validator/references/design-rules.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ for (const [key, value] of formData.entries()) {
9090
if (seen.has(key)) {
9191
issues.push({
9292
code: "duplicate_key",
93-
path: [],
9493
key,
9594
});
9695
continue; // Do not process further
@@ -216,7 +215,6 @@ for (const [key, value] of formData.entries()) {
216215
if (typeof key !== "string" || key.length === 0) {
217216
issues.push({
218217
code: "invalid_key",
219-
path: [],
220218
key,
221219
});
222220
continue;

skills/boundary-validator/references/security-rules.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ for (const [key, value] of formData.entries()) {
4747
if (FORBIDDEN_KEYS.includes(key as any)) {
4848
issues.push({
4949
code: "forbidden_key",
50-
path: [],
5150
key,
5251
});
5352
continue; // Do not process forbidden keys
@@ -70,7 +69,6 @@ const result = parse(formData);
7069
expect(result.data).toBeNull();
7170
expect(result.issues).toContainEqual({
7271
code: "forbidden_key",
73-
path: [],
7472
key: "__proto__",
7573
});
7674

@@ -82,7 +80,6 @@ const result2 = parse(formData);
8280
expect(result2.data).toBeNull();
8381
expect(result2.issues).toContainEqual({
8482
code: "forbidden_key",
85-
path: [],
8683
key: "constructor",
8784
});
8885

@@ -94,7 +91,6 @@ const result3 = parse(formData);
9491
expect(result3.data).toBeNull();
9592
expect(result3.issues).toContainEqual({
9693
code: "forbidden_key",
97-
path: [],
9894
key: "prototype",
9995
});
10096
```

0 commit comments

Comments
 (0)