zod icon indicating copy to clipboard operation
zod copied to clipboard

Feature: Add `z.jsonString` schema primitive

Open dbartholomae opened this issue 5 months ago • 14 comments

Hi there! Great work with zod and v4! Now that it's easier to tree-shake zod thanks to v4-mini, would you be open to add more commonly-used schemas as primitives? Specifically, I'm thinking about json:

// Json types from https://github.com/sindresorhus/type-fest/blob/041e67e1f180b1d6adddca57f9429789c16edf81/source/json-value.d.ts#L31 inlined here to avoid adding a dependency
export type JsonObject = {[Key in string]: JsonValue} & {[Key in string]?: JsonValue | undefined};
export type JsonArray = JsonValue[] | readonly JsonValue[];
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;

export function json() {
  return z.string().transform((str, ctx): JsonValue => {
    try {
      return JSON.parse(str) as JsonValue;
    } catch {
      ctx.addIssue({ code: "custom", message: "Invalid JSON", input: str });
      return z.NEVER;
    }
  });
}

This is such a common use case that it would be awesome to have it as part of the library.

If you're in principle open to adding this, but don't have the time due to the current changes coming with v4, I'm also very happy to provide a PR.

dbartholomae avatar Jun 07 '25 17:06 dbartholomae

Hey there, you can fine an existing JSON utility defined here https://zod.dev/api#json hope it's what you're after!

coderatchet avatar Jun 07 '25 18:06 coderatchet

Thanks! Not sure how I overlooked that. But it looks like this expects an object as input, while the type I'm suggesting is expecting a string as input and is then JSON.parsing it. Maybe adding coerce.json() would be the solution then?

dbartholomae avatar Jun 07 '25 19:06 dbartholomae

I don't think there is currently a json parser for strings, but I'm sure you could probably wrap the native JSON.parse method to achieve what you wanted, if so, you would probably not need a specific json parser anyway. Good luck

coderatchet avatar Jun 08 '25 05:06 coderatchet

Yes, JSON.parse works, see the code example above. This issue is just about making a common util available directly in zod.

dbartholomae avatar Jun 08 '25 07:06 dbartholomae

While it seems reasonable that Zod could have a z.json that automatically calls JSON.parse() during Zod's own parse() call, ultimately

  1. This ship has sailed somewhat since z.json() already expects a JSON-serializable object (this was probably an oversight in retrospect)
  2. This is achievable with transforms/pipe as others have said.

If something like your proposal was to be added it would probably be called z.jsonString() and optionally accept a post-parse schema: z.jsonString(z.object({ ... })) but I'll need to ponder whether that's worthwhile at this point.

colinhacks avatar Jun 10 '25 01:06 colinhacks

What about the coerce.json option? That feels like a natural interface and wouldn't be a breaking change.

dbartholomae avatar Jun 10 '25 06:06 dbartholomae

I don't think that aligns with the Zod's concept of "coercion" - accepting unknown input and using JS built-ins to convert to a primitive value.

colinhacks avatar Jun 10 '25 23:06 colinhacks

In the end it obviously is your call, and also jsonString could be a valuable addition - but JSON.parse is JS built-in, and the part of coerce.string that screams "primitive" is the .string, not the coerce, so coerce.json does feel to me like it fully aligns with what I would expect from coerce :)

dbartholomae avatar Jun 11 '25 06:06 dbartholomae

I wouldn't blink an eyelid at coerce simple calling JSON.parse internally and propagating the error zod-like. Then passing the output to the actual z.json() schema.

coderatchet avatar Jun 11 '25 23:06 coderatchet

I don't think that aligns with the Zod's concept of "coercion" - accepting unknown input and using JS built-ins to convert to a primitive value.

You guys are cherrypicking a bit. Your proposal is for a schema type that accepts a string (not unknown) and performs a well-defined JSON.parse operation (not a coercion).

colinhacks avatar Jun 13 '25 17:06 colinhacks

Let's maybe split into two questions then:

  1. is this parsing something that happens often enough so that it warrants being added to zod directly?
  2. if yes, what's the best way to add it

If we agree on 1, then we still have other options for 2 like jsonString in case coerce feels wrong to you.

dbartholomae avatar Jun 13 '25 19:06 dbartholomae

On the surface, it feels useful, but in reality I find most of the time other frameworks are doing the parsing of stringified JSON for me. db clients, http libraries, etc, mostly expose the object already transformed by the time I see it.

There are some cases I can think of where databases that don't natively support json types need "help" marshalling and unmarshalling these types, and in those cases, a zod integration would be useful. my current prod forms save arbitrarily structured json blobs representing draft form-data in mssql, where it currently stores as a string.

Most cases I think this benefits from is library maintainers, not library users.

coderatchet avatar Jun 13 '25 23:06 coderatchet

Some context/solutions in a previous request:

  • https://github.com/colinhacks/zod/discussions/2215

I also was expecting z.json to accept "JavaScript Object Notation", not "objects that can be turned into a JavaScript Object Notation string". This is like having z.html() accept elements (z.html().parse(document.body)). The method should have been called z.jsonifiable

The documentation is really lacking in this regard.

fregante avatar Jun 14 '25 03:06 fregante

we have similar helper in our codebase. A generic based on transform and with ability use zod to validate the contents of the json

export const json = <T extends z.ZodTypeAny>(
  type: T,
): z.ZodPipe<z.ZodTransform<unknown, unknown>, T> =>
  z.preprocess((input, ctx) => {
    if (typeof input !== "string") return input
    try {
      return JSON.parse(input)
    } catch {
      ctx.addIssue({ code: "custom", message: "Invalid JSON", input })
      return z.NEVER
    }
  }, type)

// usage
const schema = json(z.object(hello: z.string()))
schema.parse({hello: "world"}) //pass
schema.parse('{hello: "world"}') //pass
schema.parse("not a json") // fail - invalid json
schema.parse("{}") // fail - missing field hello

jakubriedl avatar Jun 23 '25 12:06 jakubriedl

Hi @colinhacks, I would like to tackle this issue by adding z.jsonString() as you mentioned above.

Problem:

Zod already has strong support for JSON-like objects (z.json()), but there’s a common gap: often data is received as stringified JSON (e.g. from form fields, DB blobs, query params).

Right now, the pattern is always two steps:

let parsed;
try {
  parsed = JSON.parse(input);
} catch {
  throw new Error("Invalid JSON");
}
const data = mySchema.parse(parsed);

This works, but introduces friction:

  • Error handling inconsistency → invalid JSON throws native SyntaxError instead of a ZodError.
  • Boilerplate everywhere → every codebase reimplements the same try/catch + JSON.parse wrapper.
  • Weaker composability → you can’t simply .pipe() or .transform() from a JSON string input in a single Zod schema.

Proposal:

Introduce z.jsonString(schema?: ZodSchema) which:

  • Accepts a string.
  • Attempts JSON.parse.
  • On failure, adds a proper Zod issue (Invalid JSON).
  • On success, runs the parsed value through the optional provided schema.

Example:

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

userSchema.parse('{"name":"Sky","age":23}'); 
// ✅ { name: "Sky", age: 23 }

userSchema.parse("not json"); 
// ❌ ZodError: Invalid JSON

Any specific guidance before i start?

Sachinkry avatar Aug 18 '25 10:08 Sachinkry