@dungarees/zod 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/json.d.ts +10 -0
- package/json.js +36 -0
- package/json.test.d.ts +1 -0
- package/json.test.js +64 -0
- package/package.json +35 -0
- package/zod.d.ts +6 -0
- package/zod.js +29 -0
- package/zod.test.d.ts +1 -0
- package/zod.test.js +43 -0
package/json.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JsonObject, JsonType } from '@dungarees/core/type-util.ts';
|
|
2
|
+
import { type ZodType } from 'zod';
|
|
3
|
+
export declare const parseJson: <PARSED>({ json, schema, message, schemaMessage, }: {
|
|
4
|
+
json: string;
|
|
5
|
+
schema: ZodType<PARSED>;
|
|
6
|
+
message?: string;
|
|
7
|
+
schemaMessage?: string;
|
|
8
|
+
}) => PARSED;
|
|
9
|
+
export declare const jsonSchema: ZodType<JsonType>;
|
|
10
|
+
export declare const jsonObjectSchema: ZodType<JsonObject>;
|
package/json.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createCausedError } from '@dungarees/core/error.ts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
// Constrained on the parsed type rather than on `ZodSchema`, which zod aliases to
|
|
4
|
+
// `ZodType<any, …>` — that would make `parsed.data` an `any` and lose the whole point.
|
|
5
|
+
// The two ways this fails need not read alike: malformed JSON is best reported with the parser's
|
|
6
|
+
// own complaint appended, while a wrong shape often has a better sentence than zod's issue list.
|
|
7
|
+
export const parseJson = ({ json, schema, message = 'Invalid JSON', schemaMessage, }) => {
|
|
8
|
+
const parsed = schema.safeParse(jsonTextToValue({ json, message }));
|
|
9
|
+
if (parsed.success) {
|
|
10
|
+
return parsed.data;
|
|
11
|
+
}
|
|
12
|
+
throw new Error(schemaMessage ?? `${message}: ${describeIssues(parsed.error)}`);
|
|
13
|
+
};
|
|
14
|
+
export const jsonSchema = z.lazy(() => z.union([
|
|
15
|
+
z.string(),
|
|
16
|
+
z.number(),
|
|
17
|
+
z.boolean(),
|
|
18
|
+
z.null(),
|
|
19
|
+
z.undefined(),
|
|
20
|
+
z.array(jsonSchema),
|
|
21
|
+
z.record(jsonSchema),
|
|
22
|
+
]));
|
|
23
|
+
// z.record rejects arrays and null, so this matches the type rather than just `typeof === 'object'`.
|
|
24
|
+
export const jsonObjectSchema = z.record(jsonSchema);
|
|
25
|
+
// JSON.parse is typed `any`; returning `unknown` forces the schema to be what narrows it.
|
|
26
|
+
const jsonTextToValue = ({ json, message }) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(json);
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
throw createCausedError({ message, cause });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const describeIssues = (error) => error.issues
|
|
35
|
+
.map(({ path, message }) => (path.length === 0 ? message : `${path.join('.')}: ${message}`))
|
|
36
|
+
.join(', ');
|
package/json.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/json.test.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsonObjectSchema, jsonSchema, parseJson } from './json.js';
|
|
2
|
+
import { expect, expectTypeOf, test } from 'vitest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
test('parseJson returns the value typed by the schema', () => {
|
|
5
|
+
const parsed = parseJson({
|
|
6
|
+
json: '{"children":{"Version":"1.2.3"}}',
|
|
7
|
+
schema: z.object({ children: z.object({ Version: z.string() }) }),
|
|
8
|
+
});
|
|
9
|
+
expectTypeOf(parsed).toEqualTypeOf();
|
|
10
|
+
expect(parsed).toEqual({ children: { Version: '1.2.3' } });
|
|
11
|
+
});
|
|
12
|
+
test('parseJson throws when the JSON parses but does not match the schema', () => {
|
|
13
|
+
expect(() => parseJson({
|
|
14
|
+
json: '{"children":{"Version":42}}',
|
|
15
|
+
schema: z.object({ children: z.object({ Version: z.string() }) }),
|
|
16
|
+
message: 'Unexpected workspace info',
|
|
17
|
+
})).toThrow('Unexpected workspace info: children.Version: Expected string, received number');
|
|
18
|
+
});
|
|
19
|
+
test('parseJson throws when the text is not JSON at all, keeping the cause', () => {
|
|
20
|
+
let thrown;
|
|
21
|
+
try {
|
|
22
|
+
parseJson({ json: 'not json', schema: z.string(), message: 'Unexpected output' });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
thrown = error;
|
|
26
|
+
}
|
|
27
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
28
|
+
expect(thrown.message).toContain('Unexpected output');
|
|
29
|
+
expect(thrown.cause).toBeInstanceOf(SyntaxError);
|
|
30
|
+
});
|
|
31
|
+
test('parseJson names the top level when the mismatch has no path', () => {
|
|
32
|
+
expect(() => parseJson({ json: '42', schema: z.string() })).toThrow('Invalid JSON: Expected string, received number');
|
|
33
|
+
});
|
|
34
|
+
test('parseJson replaces the schema issues with schemaMessage when one is given', () => {
|
|
35
|
+
expect(() => parseJson({
|
|
36
|
+
json: '{}',
|
|
37
|
+
schema: z.object({ version: z.string().min(1) }),
|
|
38
|
+
message: 'Invalid version.json',
|
|
39
|
+
schemaMessage: 'Version is required in version.json',
|
|
40
|
+
})).toThrow('Version is required in version.json');
|
|
41
|
+
});
|
|
42
|
+
test('parseJson still reports the syntax error when schemaMessage is given', () => {
|
|
43
|
+
expect(() => parseJson({
|
|
44
|
+
json: 'not json',
|
|
45
|
+
schema: z.object({ version: z.string().min(1) }),
|
|
46
|
+
message: 'Invalid version.json',
|
|
47
|
+
schemaMessage: 'Version is required in version.json',
|
|
48
|
+
})).toThrow(/^Invalid version\.json: Unexpected token/);
|
|
49
|
+
});
|
|
50
|
+
test('jsonObjectSchema accepts a nested JSON object', () => {
|
|
51
|
+
const parsed = jsonObjectSchema.parse({ a: 1, b: { c: [1, 'x', null] } });
|
|
52
|
+
expectTypeOf(parsed).toEqualTypeOf();
|
|
53
|
+
expect(parsed).toEqual({ a: 1, b: { c: [1, 'x', null] } });
|
|
54
|
+
});
|
|
55
|
+
test('jsonObjectSchema rejects anything that is not a JSON object', () => {
|
|
56
|
+
for (const value of [[], [1, 2], null, 'str', 42, true]) {
|
|
57
|
+
expect(jsonObjectSchema.safeParse(value).success).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
test('jsonSchema accepts any JSON value', () => {
|
|
61
|
+
const parsed = jsonSchema.parse({ a: [1, 'x', null, { b: true }] });
|
|
62
|
+
expectTypeOf(parsed).toEqualTypeOf();
|
|
63
|
+
expect(parsed).toEqual({ a: [1, 'x', null, { b: true }] });
|
|
64
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dungarees/zod",
|
|
3
|
+
"engines": {
|
|
4
|
+
"node": ">=22.0.0"
|
|
5
|
+
},
|
|
6
|
+
"type": "module",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@dungarees/core": "*",
|
|
9
|
+
"zod": "^3.25.76"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"vitest": "^3.0.2"
|
|
13
|
+
},
|
|
14
|
+
"author": "info@productkind.com",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"version": "0.11.4",
|
|
17
|
+
"exports": {
|
|
18
|
+
"./json.test.ts": {
|
|
19
|
+
"import": "./json.test.js",
|
|
20
|
+
"types": "./json.test.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./json.ts": {
|
|
23
|
+
"import": "./json.js",
|
|
24
|
+
"types": "./json.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./zod.test.ts": {
|
|
27
|
+
"import": "./zod.test.js",
|
|
28
|
+
"types": "./zod.test.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./zod.ts": {
|
|
31
|
+
"import": "./zod.js",
|
|
32
|
+
"types": "./zod.d.ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/zod.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GetAllPaths, GetGuarded, GetValueByPath, Guard } from '@dungarees/core/type-util.ts';
|
|
2
|
+
import { type ZodSchema, type ZodType } from 'zod';
|
|
3
|
+
export type GetSchemaType<SCHEMA = ZodSchema> = SCHEMA extends ZodSchema<infer TYPE> ? TYPE : never;
|
|
4
|
+
export declare const zodGuard: <GUARD extends Guard>(guard: GUARD, message?: string) => ZodSchema<GetGuarded<GUARD>>;
|
|
5
|
+
export declare const getSchemaByObjectPath: <const SCHEMA extends ZodSchema, const PATH extends (GetAllPaths<GetSchemaType<SCHEMA>> & string) | "">(schema: SCHEMA, path: PATH) => ZodSchema<GetValueByPath<GetSchemaType<SCHEMA>, PATH>>;
|
|
6
|
+
export declare const getSchemaByRuntimePath: (schema: ZodType<unknown>, path: string) => ZodType<unknown>;
|
package/zod.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join, split } from '@dungarees/core/util.ts';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const zodGuard = (guard, message) => {
|
|
4
|
+
return z.custom(guard, message);
|
|
5
|
+
};
|
|
6
|
+
const isObjectSchema = (schema) => 'shape' in schema;
|
|
7
|
+
export const getSchemaByObjectPath = (schema, path) => {
|
|
8
|
+
return _getSchemaByObjectPathHelper(schema, path);
|
|
9
|
+
};
|
|
10
|
+
// A path only known at runtime cannot be checked against the schema, so the caller gets an
|
|
11
|
+
// untyped schema back rather than a precise one it has not earned.
|
|
12
|
+
export const getSchemaByRuntimePath = (schema, path) => _getSchemaByObjectPathHelper(schema, path);
|
|
13
|
+
// Without the untyped helper it is an infinite loop for typecheking
|
|
14
|
+
const _getSchemaByObjectPathHelper = (schema, path) => {
|
|
15
|
+
if (path === '') {
|
|
16
|
+
return schema;
|
|
17
|
+
}
|
|
18
|
+
if (!isObjectSchema(schema)) {
|
|
19
|
+
throw new Error('Not an object schema');
|
|
20
|
+
}
|
|
21
|
+
const [firstKey, ...restPath] = split(path, '.');
|
|
22
|
+
// zod types its own shape entries as `ZodTypeAny`, so this is the one place the library's `any`
|
|
23
|
+
// is pinned down — otherwise it rides out through the return type to every caller.
|
|
24
|
+
const subschema = schema.shape[firstKey];
|
|
25
|
+
if (subschema === undefined) {
|
|
26
|
+
throw new Error('Path does not exist in schema');
|
|
27
|
+
}
|
|
28
|
+
return _getSchemaByObjectPathHelper(subschema, join(restPath, '.'));
|
|
29
|
+
};
|
package/zod.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/zod.test.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { getSchemaByObjectPath, getSchemaByRuntimePath, zodGuard } from './zod.js';
|
|
2
|
+
import { expect, test } from 'vitest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
test('zodGuard returns a custom schema based on the guard type', () => {
|
|
5
|
+
const schema = zodGuard((arg) => arg === 1);
|
|
6
|
+
const one = schema.parse(1);
|
|
7
|
+
expect(one).toBe(1);
|
|
8
|
+
});
|
|
9
|
+
test('zodGuard accepts custom error', () => {
|
|
10
|
+
const schema = zodGuard((arg) => arg === 1, 'it is not 1');
|
|
11
|
+
const result = schema.safeParse(2);
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
expect(result.error.issues[0]?.message).toBe('it is not 1');
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
test('getSchemaByObjectPath gets a single key path', () => {
|
|
17
|
+
const schema = getSchemaByObjectPath(z.object({ key1: z.literal(1), key2: z.literal(2) }), 'key1');
|
|
18
|
+
const one = schema.parse(1);
|
|
19
|
+
expect(one).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
test('getSchemaByObjectPath gets a deep key path', () => {
|
|
22
|
+
const schema = getSchemaByObjectPath(z.object({ key1: z.object({ key2: z.object({ key3: z.literal(3) }) }) }), 'key1.key2.key3');
|
|
23
|
+
const three = schema.parse(3);
|
|
24
|
+
expect(three).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
test('getSchemaByObjectPath gets the full object on empty path', () => {
|
|
27
|
+
const schema = getSchemaByObjectPath(z.object({ key1: z.literal(1), key2: z.literal(2) }), '');
|
|
28
|
+
const object = schema.parse({ key1: 1, key2: 2 });
|
|
29
|
+
expect(object).toEqual({ key1: 1, key2: 2 });
|
|
30
|
+
});
|
|
31
|
+
test('getSchemaByRuntimePath resolves a path that is only known at runtime', () => {
|
|
32
|
+
const path = 'key1.key2';
|
|
33
|
+
const schema = getSchemaByRuntimePath(z.object({ key1: z.object({ key2: z.literal(2) }) }), path);
|
|
34
|
+
expect(schema.parse(2)).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
test('getSchemaByRuntimePath throws on a path that does not exist', () => {
|
|
37
|
+
const path = 'key1.missing';
|
|
38
|
+
expect(() => getSchemaByRuntimePath(z.object({ key1: z.object({ key2: z.literal(2) }) }), path)).toThrow('Path does not exist in schema');
|
|
39
|
+
});
|
|
40
|
+
test('getSchemaByRuntimePath throws when the path goes through a non-object schema', () => {
|
|
41
|
+
const path = 'key1.key2';
|
|
42
|
+
expect(() => getSchemaByRuntimePath(z.object({ key1: z.literal(1) }), path)).toThrow('Not an object schema');
|
|
43
|
+
});
|