Skip to content

Commit a2088d7

Browse files
author
A.P.A. Slaa
committed
feat: add FormData parsing helpers
1 parent a881fbb commit a2088d7

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,25 @@ try {
8686
}
8787
```
8888

89+
Use `safeParseFormData()` or `parseFormData()` when the payload starts as `FormData`:
90+
91+
```ts
92+
const formData = new FormData();
93+
formData.set("name", " Ada ");
94+
formData.append("roles", "admin");
95+
formData.append("roles", "user");
96+
97+
const result = Validator.safeParseFormData(
98+
Validator.object({
99+
name: Validator.string({ trim: true, non_empty: true }),
100+
roles: Validator.array(Validator.enum(["admin", "user"] as const), { min: 1 }),
101+
}),
102+
formData
103+
);
104+
```
105+
106+
Repeated `FormData` keys are exposed to validators as arrays in insertion order. Single entries remain single values.
107+
89108
## Core Validators
90109

91110
### Strings
@@ -219,7 +238,9 @@ Paths are rooted at `$` and include object keys and array indexes.
219238
| `Validator.transform(base, mapper, message?, code?)` | Map validated input into a new output type. |
220239
| `Validator.refine(base, predicate, message, code?)` | Add a custom predicate to an existing validator. |
221240
| `Validator.safeParse(validator, value)` | Return `{ success, data | errors }`. |
241+
| `Validator.safeParseFormData(validator, value)` | Convert `FormData` to an object and return `{ success, data | errors }`. |
222242
| `Validator.parse(validator, value)` | Return parsed data or throw `SchemaValidationError`. |
243+
| `Validator.parseFormData(validator, value)` | Convert `FormData` to an object and return parsed data or throw. |
223244
| `ok(data)` | Build a success result manually. |
224245
| `fail(message, path, code?)` | Build a failure result manually. |
225246
| `runValidation(validator, value, path?)` | Execute a validator directly. |

src/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
3232
* Internal path representation used while walking nested values.
3333
*/
3434
export type ValidationPath = Array<string | number>;
35+
type FormDataValue = string | Blob;
3536

3637
/**
3738
* Runtime validator function.
@@ -145,6 +146,21 @@ const testPattern = (pattern: RegExp, value: string) => {
145146
return new RegExp(pattern.source, flags).test(value);
146147
};
147148

149+
const formDataToObject = (value: FormData) => {
150+
const output: Record<string, FormDataValue | FormDataValue[]> = {};
151+
152+
for (const [key, entry] of value.entries()) {
153+
const current = output[key];
154+
if (current === undefined) {
155+
output[key] = entry;
156+
continue;
157+
}
158+
output[key] = Array.isArray(current) ? [...current, entry] : [current, entry];
159+
}
160+
161+
return output;
162+
};
163+
148164
/**
149165
* Runs a validator against a value.
150166
*/
@@ -456,6 +472,13 @@ export const Validator = {
456472
safeParse: <T>(validator: Validator<T>, value: unknown): ValidationResult<T> =>
457473
runValidation(validator, value, []),
458474

475+
/**
476+
* Runs a validator against a `FormData` payload and returns a result object.
477+
* Repeated keys are exposed as arrays in insertion order.
478+
*/
479+
safeParseFormData: <T>(validator: Validator<T>, value: FormData): ValidationResult<T> =>
480+
runValidation(validator, formDataToObject(value), []),
481+
459482
/**
460483
* Runs a validator and throws `SchemaValidationError` on failure.
461484
*/
@@ -465,5 +488,17 @@ export const Validator = {
465488
throw new SchemaValidationError(result.errors);
466489
}
467490
return result.data;
491+
},
492+
493+
/**
494+
* Runs a validator against a `FormData` payload and throws on failure.
495+
* Repeated keys are exposed as arrays in insertion order.
496+
*/
497+
parseFormData: <T>(validator: Validator<T>, value: FormData): T => {
498+
const result = runValidation(validator, formDataToObject(value), []);
499+
if (isFailure(result)) {
500+
throw new SchemaValidationError(result.errors);
501+
}
502+
return result.data;
468503
}
469504
};

tests/validator.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,58 @@ describe("validator", () => {
353353
}
354354
});
355355

356+
it("parses FormData payloads through object validators", () => {
357+
const formData = new FormData();
358+
formData.set("name", " Ada ");
359+
formData.append("roles", "admin");
360+
formData.append("roles", "user");
361+
362+
const validator = Validator.object({
363+
name: Validator.string({trim: true, non_empty: true}),
364+
roles: Validator.array(Validator.enum(["admin", "user"] as const), {min: 1}),
365+
});
366+
367+
expect(Validator.safeParseFormData(validator, formData)).toEqual({
368+
success: true,
369+
data: {
370+
name: "Ada",
371+
roles: ["admin", "user"],
372+
},
373+
});
374+
expect(Validator.parseFormData(validator, formData)).toEqual({
375+
name: "Ada",
376+
roles: ["admin", "user"],
377+
});
378+
});
379+
380+
it("preserves single FormData entries and files while reporting validation failures", () => {
381+
const avatar = new File(["avatar"], "avatar.txt", {type: "text/plain"});
382+
const formData = new FormData();
383+
formData.set("name", "Ada");
384+
formData.set("avatar", avatar);
385+
formData.append("roles", "admin");
386+
formData.append("roles", "guest");
387+
388+
const validator = Validator.object({
389+
name: Validator.string(),
390+
roles: Validator.array(Validator.enum(["admin", "user"] as const), {min: 1}),
391+
}, {unknownKeys: "allow"});
392+
393+
expect(Validator.safeParseFormData(validator, formData)).toEqual(expect.objectContaining({
394+
success: false,
395+
errors: [expect.objectContaining({path: "$.roles[1]", code: "invalid_enum"})],
396+
}));
397+
398+
expect(Validator.parseFormData(
399+
Validator.object({name: Validator.string()}, {unknownKeys: "allow"}),
400+
formData
401+
)).toEqual({
402+
name: "Ada",
403+
avatar,
404+
roles: ["admin", "guest"],
405+
});
406+
});
407+
356408
it("exposes stable TypeScript inference for core validators", () => {
357409
const objectValidator = Validator.object({
358410
id: Validator.number({integer: true}),

0 commit comments

Comments
 (0)