Skip to content
Open
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: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@arethetypeswrong/cli": "^0.17.4",
"@biomejs/biome": "^1.9.4",
"@types/benchmark": "^2.1.5",
"@types/node": "^24.3.0",
"@types/semver": "^7.7.0",
"@web-std/file": "^3.0.3",
"arktype": "^2.1.19",
Expand Down
35 changes: 35 additions & 0 deletions packages/docs/content/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ z.ipv6();
z.cidrv4(); // ipv4 CIDR block
z.cidrv6(); // ipv6 CIDR block
z.hash("sha256"); // or "sha1", "sha384", "sha512", "md5"
z.jsonString(); // validates JSON syntax
z.iso.date();
z.iso.time();
z.iso.datetime();
Expand Down Expand Up @@ -388,6 +389,40 @@ new URL("HTTP://ExAmPle.com:80/./a/../b?X=1#f oo").href
// => "http://example.com/b?X=1#f%20oo"
```

### JSON Strings

To validate that a string contains valid JSON syntax:

```ts
const schema = z.jsonString();

schema.parse('{"name": "Alice", "age": 30}'); // ✅
schema.parse('{"name": "Alice", "age": 30}'); // ✅ (returns original string)
schema.parse("42"); // ✅
schema.parse("[1, 2, 3]"); // ✅
schema.parse("invalid json"); // ❌
```

When used without an inner schema, `z.jsonString()` validates JSON syntax and returns the original string. This is useful for validating JSON strings without parsing them.

To parse JSON and validate the parsed value against a schema:

```ts
const userSchema = z.object({
name: z.string(),
age: z.number(),
});

const schema = z.jsonString(userSchema);

schema.parse('{"name": "Alice", "age": 30}'); // ✅ { name: "Alice", age: 30 }
schema.parse('{"name": "Alice", "age": "30"}'); // ❌ (age should be number)
schema.parse('{"name": "Alice"}'); // ❌ (missing age)
schema.parse("invalid json"); // ❌ (invalid JSON syntax)
```

The `z.jsonString()` method supports all JSON types (objects, arrays, strings, numbers, booleans, null) and provides proper error reporting for both JSON syntax errors and schema validation errors.

### ISO datetimes

As you may have noticed, Zod string includes a few date/time related validations. These validations are regular expression based, so they are not as strict as a full date/time library. However, they are very convenient for validating user input.
Expand Down
22 changes: 22 additions & 0 deletions packages/docs/content/v4/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -903,3 +903,25 @@ const longString = z.string().refine((val) => val.length > 10, {
- ZodEnum and ZodNativeEnum are merged
- `.Values` and `.Enum` are removed. Use `.enum` instead.
- `.options` is removed */}

## New features

### JSON String validation

Zod 4 introduces `z.jsonString()` for validating and parsing JSON strings:

```ts
// Validate JSON syntax only
const schema = z.jsonString();
schema.parse('{"name": "Alice", "age": 30}'); // ✅ returns original string
schema.parse("invalid json"); // ❌ throws error

// Parse JSON and validate structure
const userSchema = z.jsonString(z.object({
name: z.string(),
age: z.number(),
}));
userSchema.parse('{"name": "Alice", "age": 30}'); // ✅ returns { name: "Alice", age: 30 }
```

This feature is useful for validating JSON strings from external sources (like API responses or user input) and provides proper error reporting for both JSON syntax errors and schema validation errors.
27 changes: 27 additions & 0 deletions packages/zod/src/v4/classic/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2203,3 +2203,30 @@ export function preprocess<A, U extends core.SomeType, B = unknown>(
): ZodPipe<ZodTransform<A, B>, U> {
return pipe(transform(fn as any), schema as any) as any;
}

// ZodJSONString
export interface ZodJSONString extends ZodStringFormat<"json_string"> {
_zod: core.$ZodJSONStringInternals;
}
export const ZodJSONString: core.$constructor<ZodJSONString> = /*@__PURE__*/ core.$constructor(
"ZodJSONString",
(inst, def) => {
core.$ZodJSONString.init(inst, def);
ZodStringFormat.init(inst, def);
}
);

export function jsonString(params?: string | core.$ZodJSONStringParams): ZodJSONString;
export function jsonString<T extends core.$ZodType>(
inner: T,
params?: string | core.$ZodJSONStringParams
): ZodPipe<ZodTransform<any, string>, T>;
export function jsonString(innerOrParams?: any, maybeParams?: any): any {
if (innerOrParams && typeof innerOrParams === "object" && "_zod" in innerOrParams) {
// inner schema provided: create a pipe String -> JSON.parse -> inner
const inner = innerOrParams as core.$ZodType;
const params = maybeParams;
return core._jsonStringPipe({ Pipe: ZodPipe, Transform: ZodTransform, String: ZodString }, inner, params);
}
return core._jsonString(ZodJSONString, innerOrParams);
}
105 changes: 105 additions & 0 deletions packages/zod/src/v4/classic/tests/jsonString.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect, test } from "vitest";
import * as z from "zod/v4";

test("jsonString() validates JSON syntax", () => {
const schema = z.jsonString();

// Valid JSON
expect(schema.parse("{}")).toBe("{}");
expect(schema.parse("42")).toBe("42");
expect(schema.parse('"hello"')).toBe('"hello"');
expect(schema.parse("true")).toBe("true");
expect(schema.parse("null")).toBe("null");
expect(schema.parse("[1,2,3]")).toBe("[1,2,3]");

// Invalid JSON
expect(() => schema.parse("")).toThrow();
expect(() => schema.parse("invalid")).toThrow();
expect(() => schema.parse("{")).toThrow();
expect(() => schema.parse('{"key":}')).toThrow();
expect(() => schema.parse('{"key": "value",}')).toThrow();
});

test("jsonString() preserves whitespace and formatting", () => {
const schema = z.jsonString();
expect(schema.parse(' {"key": "value"} ')).toBe(' {"key": "value"} ');
expect(schema.parse('\n\t{"key": "value"}\n')).toBe('\n\t{"key": "value"}\n');
});

test("jsonString() handles unicode and special characters", () => {
const schema = z.jsonString();
expect(schema.parse('"Hello 世界"')).toBe('"Hello 世界"');
expect(schema.parse('"🎉🎊🎈"')).toBe('"🎉🎊🎈"');
expect(schema.parse('"\\"quoted\\""')).toBe('"\\"quoted\\""');
expect(schema.parse('{"key世界": "value"}')).toBe('{"key世界": "value"}');
});

test("jsonString() rejects malformed nested structures", () => {
const schema = z.jsonString();
expect(() => schema.parse('{"a": {"b": {"c":}')).toThrow();
expect(() => schema.parse('{"a": [1, 2, 3}')).toThrow();
expect(() => schema.parse('{"a": [1, 2, 3]]')).toThrow();
});

test("jsonString() with inner schema parses and validates", () => {
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const schema = z.jsonString(userSchema);

// Valid
const valid = schema.parse('{"id": 123, "name": "John", "email": "[email protected]"}');
expect(valid).toEqual({ id: 123, name: "John", email: "[email protected]" });

// Missing fields
const missing = schema.safeParse('{"id": 123}');
expect(missing.success).toBe(false);
if (!missing.success) {
expect(missing.error.issues.some((issue) => issue.path.includes("name"))).toBe(true);
}

// Invalid JSON syntax
const invalidSyntax = schema.safeParse('{"id": 123, "name": "John", "email":');
expect(invalidSyntax.success).toBe(false);
if (!invalidSyntax.success) {
expect(invalidSyntax.error.issues[0].code).toBe("invalid_format");
expect((invalidSyntax.error.issues[0] as any).format).toBe("json_string");
}
});

test("jsonString() with primitive schemas", () => {
expect(z.jsonString(z.number()).parse("42")).toBe(42);
expect(z.jsonString(z.string()).parse('"hello"')).toBe("hello");
expect(z.jsonString(z.boolean()).parse("true")).toBe(true);
expect(z.jsonString(z.array(z.number())).parse("[1,2,3]")).toEqual([1, 2, 3]);
});

test("jsonString() with union schemas", () => {
const unionSchema = z.union([z.string(), z.number()]);
const schema = z.jsonString(unionSchema);
expect(schema.parse('"hello"')).toBe("hello");
expect(schema.parse("42")).toBe(42);
expect(() => schema.parse("true")).toThrow();
});

test("jsonString() handles large payloads", () => {
const schema = z.jsonString();
const largeObject = {
users: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `User ${i}` })),
};
const largeJson = JSON.stringify(largeObject);
expect(schema.parse(largeJson)).toBe(largeJson);
});

test("jsonString() works with async parsing", async () => {
const schema = z.jsonString(z.object({ name: z.string() }));
const result = await schema.parseAsync('{"name": "Alice"}');
expect(result).toEqual({ name: "Alice" });
});

test("jsonString() works with transforms", () => {
const schema = z.jsonString(z.object({ count: z.number() })).transform((data) => data.count * 2);
expect(schema.parse('{"count": 5}')).toBe(10);
});
68 changes: 68 additions & 0 deletions packages/zod/src/v4/core/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1619,3 +1619,71 @@ export function _stringFormat<Format extends string>(
const inst = new Class(def);
return inst as any;
}

// JSON String
export type $ZodJSONStringParams = StringFormatParams<schemas.$ZodJSONString, "when">;
export type $ZodCheckJSONStringParams = CheckStringFormatParams<schemas.$ZodJSONString, "when">;
export function _jsonString<T extends schemas.$ZodJSONString>(
Class: util.SchemaClass<T>,
params?: string | $ZodJSONStringParams | $ZodCheckJSONStringParams
): T {
return new Class({
type: "string",
format: "json_string",
check: "string_format",
abort: false,
...util.normalizeParams(params),
});
}

export function _jsonStringPipe(
Classes: {
Pipe?: typeof schemas.$ZodPipe;
Transform?: typeof schemas.$ZodTransform;
String?: typeof schemas.$ZodString;
},
inner: schemas.$ZodType,
_params?: string | $ZodJSONStringParams | $ZodCheckJSONStringParams
): schemas.$ZodPipe<schemas.$ZodPipe<schemas.$ZodString, schemas.$ZodTransform<unknown, string>>, typeof inner> {
const params = util.normalizeParams(_params);

const _Pipe = Classes.Pipe ?? schemas.$ZodPipe;
const _String = Classes.String ?? schemas.$ZodString;
const _Transform = Classes.Transform ?? schemas.$ZodTransform;

const tx = new _Transform({
type: "transform",
transform: (input, payload: schemas.ParsePayload<unknown>) => {
try {
return JSON.parse(input as string);
} catch (_) {
payload.issues.push({
origin: "string",
code: "invalid_format",
format: "json_string",
input: payload.value as string,
inst: tx,
continue: false,
});
return {} as never;
}
},
error: params.error,
});

const innerPipe = new _Pipe({
type: "pipe",
in: new _String({ type: "string", error: params.error }),
out: tx,
error: params.error,
});

const outerPipe = new _Pipe({
type: "pipe",
in: innerPipe,
out: inner,
error: params.error,
});

return outerPipe as any;
}
54 changes: 25 additions & 29 deletions packages/zod/src/v4/core/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,38 +861,34 @@ export const $ZodCheckRegex: core.$constructor<$ZodCheckRegex> = /*@__PURE__*/ c
///////////////////////////////////
///// $ZodCheckJSONString /////
///////////////////////////////////
// interface $ZodCheckJSONStringDef extends $ZodCheckStringFormatDef<"json_string"> {
// // check: "string_format";
// // format: "json_string";
// // error?: errors.$ZodErrorMap<errors.$ZodIssueInvalidStringFormat> | undefined;
// }
interface $ZodCheckJSONStringDef extends $ZodCheckStringFormatDef<"json_string"> {}

// export interface $ZodCheckJSONString extends $ZodCheckStringFormat {
// _def: $ZodCheckJSONStringDef;
// }
export interface $ZodCheckJSONString extends $ZodCheckStringFormat {
_zod: $ZodCheckStringFormatInternals & { def: $ZodCheckJSONStringDef };
}

// export const $ZodCheckJSONString: core.$constructor<$ZodCheckJSONString> = /*@__PURE__*/ core.$constructor(
// "$ZodCheckJSONString",
// (inst, def) => {
// $ZodCheck.init(inst, def);
export const $ZodCheckJSONString: core.$constructor<$ZodCheckJSONString> = /*@__PURE__*/ core.$constructor(
"$ZodCheckJSONString",
(inst, def) => {
$ZodCheckStringFormat.init(inst, def);

// inst._zod.check = (payload) => {
// try {
// JSON.parse(payload.value);
// return;
// } catch (_) {
// payload.issues.push({
// origin: "string",
// code: "invalid_format",
// format: def.format,
// input: payload.value,
// inst,
// continue: !def.abort,
// });
// }
// };
// }
// );
inst._zod.check = (payload) => {
try {
JSON.parse(payload.value);
return;
} catch (_) {
payload.issues.push({
origin: "string",
code: "invalid_format",
format: def.format,
input: payload.value,
inst,
continue: !def.abort,
});
}
};
}
);

//////////////////////////////////////
///// $ZodCheckLowerCase /////
Expand Down
33 changes: 32 additions & 1 deletion packages/zod/src/v4/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4295,4 +4295,35 @@ export type $ZodStringFormatTypes =
| $ZodJWT
| $ZodCustomStringFormat<"hex">
| $ZodCustomStringFormat<util.HashFormat>
| $ZodCustomStringFormat<"hostname">;
| $ZodCustomStringFormat<"hostname">
| $ZodJSONString;

////////////////////////////// ZodJSONString //////////////////////////////

export interface $ZodJSONStringDef extends $ZodStringFormatDef<"json_string"> {}
export interface $ZodJSONStringInternals extends $ZodStringFormatInternals<"json_string"> {}

export interface $ZodJSONString extends $ZodType {
_zod: $ZodJSONStringInternals;
}

export const $ZodJSONString: core.$constructor<$ZodJSONString> = /*@__PURE__*/ core.$constructor(
"$ZodJSONString",
(inst, def) => {
$ZodStringFormat.init(inst, def);
inst._zod.check = (payload) => {
try {
JSON.parse(payload.value);
return;
} catch (_) {
payload.issues.push({
code: "invalid_format",
format: "json_string",
input: payload.value,
inst,
continue: !def.abort,
});
}
};
}
);
Loading