Repository navigation
Expand file tree
/
Copy pathlenient.ts
More file actions
158 lines (141 loc) · 5.16 KB
/
Copy pathlenient.ts
File metadata and controls
158 lines (141 loc) · 5.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import { z } from 'zod';
type Issue = z.core.$ZodIssue;
type Schema = z.ZodType;
type Path = PropertyKey[];
/** Issues the current lenient parse has caught. Parsing is synchronous, so one sink is enough. */
let sink: Issue[] | null = null;
function isFatal(schema: Schema): boolean {
return z.globalRegistry.get(schema)?.fatal === true;
}
/** The schemas directly inside another, for walking the tree. */
function children(schema: Schema): Schema[] {
const def = schema._zod.def as z.core.$ZodTypeDef & Record<string, unknown>;
switch (def.type) {
case 'object':
return [
...Object.values(def.shape as Record<string, Schema>),
...(def.catchall ? [def.catchall as Schema] : []),
];
case 'union':
return def.options as Schema[];
case 'pipe':
return [def.in as Schema, def.out as Schema];
case 'array':
return [def.element as Schema];
case 'record':
return [def.keyType as Schema, def.valueType as Schema];
case 'tuple':
return [...(def.items as Schema[]), ...(def.rest ? [def.rest as Schema] : [])];
default:
return def.innerType ? [def.innerType as Schema] : [];
}
}
function containsFatal(schema: Schema): boolean {
return isFatal(schema) || children(schema).some(containsFatal);
}
/** A leaf that falls back to its raw input instead of failing the parse, recording why. */
function catchLeaf(schema: Schema, path: Path): Schema {
return schema.catch((ctx: z.core.$ZodCatchCtx) => {
for (const issue of ctx.error.issues) {
sink?.push({ ...issue, path: [...path, ...issue.path] });
}
return ctx.value as never;
});
}
/**
* A copy of `schema` that never fails on a bad leaf.
*
* Each leaf is wrapped in `.catch()`, so a bad value falls back to its raw input
* and the rest of the parse - transforms and defaults included - still runs.
* The wrapper knows its own key path, which `ctx.error` does not. Containers
* are rebuilt around their wrapped children, so one bad leaf costs only that
* leaf, and are caught too, so a failure of the container itself - a missing
* section, an unknown key in a strict object - costs only that section.
*
* Left unwrapped, so they still fail the whole parse:
* - anything marked `.meta({fatal: true})`, and everything holding one, so the
* failure reaches the top
* - a discriminated union's discriminator, so an unknown value can't match
* every option. The union as a whole falls back instead.
*
* A union's options and an object a transform reads aren't caught themselves:
* zod needs an option to be a plain object, and a transform must never be
* handed a raw fallback for its whole input. The union or transform is caught
* instead.
*/
export function lenient<T extends Schema>(schema: T, path: Path = []): T {
return wrap(schema, path) as T;
}
interface WrapOptions {
/** This object is a discriminated union's option, keyed on this property. */
discriminator?: string;
/** Leave this container's own failures to whatever holds it. */
bare?: boolean;
}
function wrap(schema: Schema, path: Path, { discriminator, bare }: WrapOptions = {}): Schema {
if (isFatal(schema)) {
return schema;
}
// a catch here would swallow a fatal failure from anywhere inside
const guard = (rebuilt: Schema) =>
bare || containsFatal(schema) ? rebuilt : catchLeaf(rebuilt, path);
const def = schema._zod.def as z.core.$ZodTypeDef & Record<string, unknown>;
switch (def.type) {
case 'object': {
const shape = def.shape as Record<string, Schema>;
const wrapped = Object.fromEntries(
Object.entries(shape).map(([key, child]) => [
key,
key === discriminator ? child : wrap(child, [...path, key]),
]),
);
return guard(schema.clone({ ...def, shape: wrapped } as typeof def));
}
case 'optional':
case 'nullable':
case 'default':
case 'prefault':
case 'readonly':
return schema.clone({
...def,
innerType: wrap(def.innerType as Schema, path, { bare }),
} as typeof def);
case 'pipe': {
// an object-level transform: a bad leaf inside it falls back on its own, so
// the transform still runs; a bad object as a whole fails the pipe
const input = def.in as Schema;
if (input._zod.def.type === 'object') {
return guard(schema.clone({ ...def, in: wrap(input, path, { bare: true }) } as typeof def));
}
return guard(schema);
}
case 'union': {
const key = def.discriminator as string | undefined;
if (!key) {
return guard(schema);
}
const options = (def.options as Schema[]).map((option) =>
wrap(option, path, { discriminator: key, bare: true }),
);
return guard(schema.clone({ ...def, options } as typeof def));
}
default:
return guard(schema);
}
}
/**
* Parse with `lenient(schema)`, returning the data and every issue it caught.
* A fatal key still throws, as a ZodError.
*/
export function parseLeniently<T extends Schema>(
schema: T,
input: unknown,
): { data: z.output<T>; issues: Issue[] } {
const issues: Issue[] = [];
sink = issues;
try {
return { data: schema.parse(input), issues };
} finally {
sink = null;
}
}