Skip to content

Commit ad2fc5e

Browse files
authored
Implement OpenAPI-friendly JSON Schema for File schemas (#4567)
* Implement OpenAPI-friendly JSON Schema for File schemas * Fix unrepresentable tests
1 parent f97733f commit ad2fc5e

File tree

8 files changed

+114
-10
lines changed

8 files changed

+114
-10
lines changed

packages/docs/content/api.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,6 +1630,38 @@ z.set(z.string()).check(z.size(5)); // must contain 5 items exactly
16301630
</Tab>
16311631
</Tabs>
16321632

1633+
## Files
1634+
1635+
To validate `File` instances:
1636+
1637+
<Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
1638+
<Tab value="Zod">
1639+
```ts
1640+
const fileSchema = z.file();
1641+
1642+
fileSchema.min(10_000); // minimum .size (bytes)
1643+
fileSchema.max(1_000_000); // maximum .size (bytes)
1644+
fileSchema.mime(["image/png"]); // MIME type
1645+
```
1646+
1647+
</Tab>
1648+
<Tab value='zod/v4-mini'>
1649+
```ts
1650+
const fileSchema = z.file();
1651+
1652+
fileSchema.check(
1653+
z.minSize(10_000), // minimum .size (bytes)
1654+
z.maxSize(1_000_000), // maximum .size (bytes)
1655+
z.mime(["image/png"]); // MIME type
1656+
)
1657+
```
1658+
</Tab>
1659+
</Tabs>
1660+
1661+
1662+
1663+
1664+
16331665
## Promises
16341666

16351667
<Callout type="warn">
@@ -2446,6 +2478,7 @@ const jsonSchema = z.lazy(() => {
24462478
});
24472479
```
24482480

2481+
24492482
## Custom
24502483

24512484
You can create a Zod schema for any TypeScript type by using `z.custom()`. This is useful for creating schemas for types that are not supported by Zod out of the box, such as template string literals.

packages/docs/content/json-schema.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ z.toJSONSchema(schema)
2626
// type: 'object',
2727
// properties: { name: { type: 'string' }, age: { type: 'number' } },
2828
// required: [ 'name', 'age' ]
29+
// additionalProperties: false
2930
// }
3031
```
3132

@@ -312,7 +313,6 @@ z.void(); // ❌
312313
z.date(); //
313314
z.map(); //
314315
z.set(); //
315-
z.file(); //
316316
z.transform(); //
317317
z.nan(); //
318318
z.custom(); //
@@ -348,6 +348,7 @@ toJSONSchema(User);
348348
// type: 'object',
349349
// properties: { name: { type: 'string' }, friend: { '$ref': '#' } },
350350
// required: [ 'name', 'friend' ]
351+
// additionalProperties: false
351352
// }
352353
```
353354

packages/docs/content/v4/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ const fileSchema = z.file();
584584
585585
fileSchema.min(10_000); // minimum .size (bytes)
586586
fileSchema.max(1_000_000); // maximum .size (bytes)
587-
fileSchema.type("image/png"); // MIME type
587+
fileSchema.mime(["image/png"]); // MIME type
588588
```
589589
590590
## Internationalization

packages/zod/src/v4/classic/schemas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,15 +1554,15 @@ export interface ZodFile extends ZodType {
15541554

15551555
min(size: number, params?: string | core.$ZodCheckMinSizeParams): this;
15561556
max(size: number, params?: string | core.$ZodCheckMaxSizeParams): this;
1557-
mime(types: Array<util.MimeTypes>, params?: string | core.$ZodCheckMimeTypeParams): this;
1557+
mime(types: util.MimeTypes | Array<util.MimeTypes>, params?: string | core.$ZodCheckMimeTypeParams): this;
15581558
}
15591559
export const ZodFile: core.$constructor<ZodFile> = /*@__PURE__*/ core.$constructor("ZodFile", (inst, def) => {
15601560
core.$ZodFile.init(inst, def);
15611561
ZodType.init(inst, def);
15621562

15631563
inst.min = (size, params) => inst.check(core._minSize(size, params));
15641564
inst.max = (size, params) => inst.check(core._maxSize(size, params));
1565-
inst.mime = (types, params) => inst.check(core._mime(types, params));
1565+
inst.mime = (types, params) => inst.check(core._mime(Array.isArray(types) ? types : [types], params));
15661566
});
15671567

15681568
export function file(params?: string | core.$ZodFileParams): ZodFile {

packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,6 @@ describe("toJSONSchema", () => {
237237
expect(() => z.toJSONSchema(z.set(z.string()))).toThrow("Set cannot be represented in JSON Schema");
238238
expect(() => z.toJSONSchema(z.custom(() => true))).toThrow("Custom types cannot be represented in JSON Schema");
239239

240-
// File type
241-
const fileSchema = z.file();
242-
expect(() => z.toJSONSchema(fileSchema)).toThrow("File cannot be represented in JSON Schema");
243-
244240
// Transform
245241
const transformSchema = z.string().transform((val) => Number.parseInt(val));
246242
expect(() => z.toJSONSchema(transformSchema)).toThrow("Transforms cannot be represented in JSON Schema");
@@ -2047,3 +2043,43 @@ test("flatten simple intersections", () => {
20472043
}
20482044
`);
20492045
});
2046+
2047+
test("z.file()", () => {
2048+
const a = z.file().meta({ describe: "File" }).mime("image/png").min(1000).max(10000);
2049+
expect(z.toJSONSchema(a)).toMatchInlineSnapshot(`
2050+
{
2051+
"$schema": "https://json-schema.org/draft/2020-12/schema",
2052+
"contentEncoding": "binary",
2053+
"contentMediaType": "image/png",
2054+
"format": "binary",
2055+
"maxLength": 10000,
2056+
"minLength": 1000,
2057+
"type": "string",
2058+
}
2059+
`);
2060+
2061+
const b = z.file().mime(["image/png", "image/jpg"]).min(1000).max(10000);
2062+
expect(z.toJSONSchema(b)).toMatchInlineSnapshot(`
2063+
{
2064+
"$schema": "https://json-schema.org/draft/2020-12/schema",
2065+
"anyOf": [
2066+
{
2067+
"contentEncoding": "binary",
2068+
"contentMediaType": "image/png",
2069+
"format": "binary",
2070+
"maxLength": 10000,
2071+
"minLength": 1000,
2072+
"type": "string",
2073+
},
2074+
{
2075+
"contentEncoding": "binary",
2076+
"contentMediaType": "image/jpg",
2077+
"format": "binary",
2078+
"maxLength": 10000,
2079+
"minLength": 1000,
2080+
"type": "string",
2081+
},
2082+
],
2083+
}
2084+
`);
2085+
});

packages/zod/src/v4/core/schemas.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2779,6 +2779,11 @@ export interface $ZodFileDef extends $ZodTypeDef {
27792779
export interface $ZodFileInternals extends $ZodTypeInternals<File, File> {
27802780
def: $ZodFileDef;
27812781
isst: errors.$ZodIssueInvalidType;
2782+
bag: util.LoosePartial<{
2783+
minimum: number;
2784+
maximum: number;
2785+
mime: util.MimeTypes[];
2786+
}>;
27822787
}
27832788

27842789
export interface $ZodFile extends $ZodType {

packages/zod/src/v4/core/to-json-schema.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,33 @@ export class JSONSchemaGenerator {
433433
}
434434
break;
435435
}
436+
436437
case "file": {
437-
if (this.unrepresentable === "throw") {
438-
throw new Error("File cannot be represented in JSON Schema");
438+
const json: JSONSchema.StringSchema = _json as any;
439+
const file: JSONSchema.StringSchema = {
440+
type: "string",
441+
format: "binary",
442+
contentEncoding: "binary",
443+
};
444+
445+
const { minimum, maximum, mime } = schema._zod.bag as schemas.$ZodFileInternals["bag"];
446+
if (minimum !== undefined) file.minLength = minimum;
447+
if (maximum !== undefined) file.maxLength = maximum;
448+
if (mime) {
449+
if (mime.length === 1) {
450+
file.contentMediaType = mime[0];
451+
Object.assign(json, file);
452+
} else {
453+
json.anyOf = mime.map((m) => {
454+
const mFile: JSONSchema.StringSchema = { ...file, contentMediaType: m };
455+
return mFile;
456+
});
457+
}
439458
}
459+
460+
// if (this.unrepresentable === "throw") {
461+
// throw new Error("File cannot be represented in JSON Schema");
462+
// }
440463
break;
441464
}
442465
case "transform": {

play.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ const HelloSchema = FirstSchema.and(SecondSchema).and(ThirdSchema).describe("123
1717
// Zod 4
1818
const result = z.toJSONSchema(HelloSchema, { target: "draft-7" });
1919
console.dir(result, { depth: null });
20+
21+
const fileSchema = z.file();
22+
23+
fileSchema.min(10_000); // minimum .size (bytes)
24+
fileSchema.max(1_000_000); // maximum .size (bytes)
25+
fileSchema.mime(["image/png"]); // MIME type

0 commit comments

Comments
 (0)