Skip to content

Commit

Permalink
Add z.string().json(...) helper (colinhacks#3109)
Browse files Browse the repository at this point in the history
* Add z.string().json(...) helper

* use parseAsync + await expect(...).rejects

* make it work in deno

* Add overload

---------

Co-authored-by: Misha Kaletsky <[email protected]>
Co-authored-by: Colin McDonnell <[email protected]>
  • Loading branch information
3 people committed May 3, 2024
1 parent 2f6493d commit 941965b
Show file tree
Hide file tree
Showing 10 changed files with 363 additions and 3 deletions.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [JSON](#json)
- [Numbers](#numbers)
- [BigInts](#bigints)
- [NaNs](#nans)
Expand Down Expand Up @@ -783,6 +784,44 @@ const ipv6 = z.string().ip({ version: "v6" });
ipv6.parse("192.168.1.1"); // fail
```

### JSON

The `z.string().json(...)` method parses strings as JSON, then [pipes](#pipe) the result to another specified schema.

```ts
const Env = z.object({
API_CONFIG: z.string().json(
z.object({
host: z.string(),
port: z.number().min(1000).max(2000),
})
),
SOME_OTHER_VALUE: z.string(),
});

const env = Env.parse({
API_CONFIG: '{ "host": "example.com", "port": 1234 }',
SOME_OTHER_VALUE: "abc123",
});

env.API_CONFIG.host; // returns parsed value
```

If invalid JSON is encountered, the syntax error will be wrapped and put into a parse error:

```ts
const env = Env.safeParse({
API_CONFIG: "not valid json!",
SOME_OTHER_VALUE: "abc123",
});

if (!env.success) {
console.log(env.error); // ... Unexpected token n in JSON at position 0 ...
}
```

This is recommended over using `z.string().transform(s => JSON.parse(s))`, since that will not catch parse errors, even when using `.safeParse`.

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
39 changes: 39 additions & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [JSON](#json)
- [Numbers](#numbers)
- [BigInts](#bigints)
- [NaNs](#nans)
Expand Down Expand Up @@ -783,6 +784,44 @@ const ipv6 = z.string().ip({ version: "v6" });
ipv6.parse("192.168.1.1"); // fail
```

### JSON

The `z.string().json(...)` method parses strings as JSON, then [pipes](#pipe) the result to another specified schema.

```ts
const Env = z.object({
API_CONFIG: z.string().json(
z.object({
host: z.string(),
port: z.number().min(1000).max(2000),
})
),
SOME_OTHER_VALUE: z.string(),
});

const env = Env.parse({
API_CONFIG: '{ "host": "example.com", "port": 1234 }',
SOME_OTHER_VALUE: "abc123",
});

env.API_CONFIG.host; // returns parsed value
```

If invalid JSON is encountered, the syntax error will be wrapped and put into a parse error:

```ts
const env = Env.safeParse({
API_CONFIG: "not valid json!",
SOME_OTHER_VALUE: "abc123",
});

if (!env.success) {
console.log(env.error); // ... Unexpected token n in JSON at position 0 ...
}
```

This is recommended over using `z.string().transform(s => JSON.parse(s))`, since that will not catch parse errors, even when using `.safeParse`.

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
4 changes: 4 additions & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ export interface ZodInvalidDateIssue extends ZodIssueBase {
export type StringValidation =
| "email"
| "url"
<<<<<<< HEAD
| "jwt"
=======
| "json"
>>>>>>> c5a2690 (Add z.string().json(...) helper)
| "emoji"
| "uuid"
| "nanoid"
Expand Down
102 changes: 102 additions & 0 deletions deno/lib/__tests__/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @ts-ignore TS6133
import { expect } from "https://deno.land/x/[email protected]/mod.ts";
const test = Deno.test;

import * as z from "../index.ts";

// @ts-ignore TS2304
const isDeno = typeof Deno === "object";

test("overload types", () => {
const schema = z.string().json();
z.util.assertEqual<typeof schema, z.ZodString>(true);
const schema2 = z.string().json(z.number());
z.util.assertEqual<
typeof schema2,
z.ZodPipeline<z.ZodEffects<z.ZodString, any, string>, z.ZodNumber>
>(true);
const r2 = schema2.parse("12");
z.util.assertEqual<number, typeof r2>(true);
});
test("parse string to json", async () => {
const Env = z.object({
myJsonConfig: z.string().json(z.object({ foo: z.number() })),
someOtherValue: z.string(),
});

expect(
Env.parse({
myJsonConfig: '{ "foo": 123 }',
someOtherValue: "abc",
})
).toEqual({
myJsonConfig: { foo: 123 },
someOtherValue: "abc",
});

const invalidValues = Env.safeParse({
myJsonConfig: '{"foo": "not a number!"}',
someOtherValue: null,
});
expect(JSON.parse(JSON.stringify(invalidValues))).toEqual({
success: false,
error: {
name: "ZodError",
issues: [
{
code: "invalid_type",
expected: "number",
received: "string",
path: ["myJsonConfig", "foo"],
message: "Expected number, received string",
},
{
code: "invalid_type",
expected: "string",
received: "null",
path: ["someOtherValue"],
message: "Expected string, received null",
},
],
},
});

const invalidJsonSyntax = Env.safeParse({
myJsonConfig: "This is not valid json",
someOtherValue: null,
});
expect(JSON.parse(JSON.stringify(invalidJsonSyntax))).toEqual({
success: false,
error: {
name: "ZodError",
issues: [
{
code: "invalid_string",
validation: "json",
message: "Invalid json",
path: ["myJsonConfig"],
},
{
code: "invalid_type",
expected: "string",
received: "null",
path: ["someOtherValue"],
message: "Expected string, received null",
},
],
},
});
});

test("no argument", () => {
const schema = z.string().json();
z.util.assertEqual<typeof schema, z.ZodString>(true);
z.string().json().parse(`{}`);
z.string().json().parse(`null`);
z.string().json().parse(`12`);
z.string().json().parse(`{ "test": "test"}`);
expect(() => z.string().json().parse(`asdf`)).toThrow();
expect(() => z.string().json().parse(`{ "test": undefined }`)).toThrow();
expect(() => z.string().json().parse(`{ "test": 12n }`)).toThrow();
expect(() => z.string().json().parse(`{ test: "test" }`)).toThrow();
});
39 changes: 39 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
<<<<<<< HEAD
| {
kind: "date";
// withDate: true;
Expand All @@ -582,6 +583,10 @@ export type ZodStringCheck =
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };
=======
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "json"; message?: string };
>>>>>>> ca9c3e1 (Add overload)

export interface ZodStringDef extends ZodTypeDef {
checks: ZodStringCheck[];
Expand Down Expand Up @@ -1019,12 +1024,23 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
<<<<<<< HEAD
} else if (check.kind === "base64") {
if (!base64Regex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "base64",
code: ZodIssueCode.invalid_string,
=======
} else if (check.kind === "json") {
try {
JSON.parse(input.data);
} catch (err) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_string,
validation: "json",
>>>>>>> ca9c3e1 (Add overload)
message: check.message,
});
status.dirty();
Expand Down Expand Up @@ -1199,6 +1215,29 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
}

json(message?: errorUtil.ErrMessage): this;
json<T extends ZodTypeAny>(
pipeTo: T
): ZodPipeline<ZodEffects<this, any, input<this>>, T>;
json(input?: errorUtil.ErrMessage | ZodTypeAny) {
if (!(input instanceof ZodType)) {
return this._addCheck({ kind: "json", ...errorUtil.errToObj(input) });
}
const schema = this.transform((val, ctx) => {
try {
return JSON.parse(val);
} catch (error: unknown) {
ctx.addIssue({
code: ZodIssueCode.invalid_string,
validation: "json",
// message: (error as Error).message,
});
return NEVER;
}
});
return input ? schema.pipe(input) : schema;
}

min(minLength: number, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "min",
Expand Down
2 changes: 1 addition & 1 deletion playground.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { z } from "./src/index";

z.string().parse("asdf");
z;
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export type StringValidation =
| "email"
| "url"
| "jwt"
| "json"
| "emoji"
| "uuid"
| "nanoid"
Expand Down
Loading

0 comments on commit 941965b

Please sign in to comment.