/
helpers.ts
192 lines (173 loc) · 5.71 KB
/
helpers.ts
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
import { setPath } from "set-get";
import {
z,
ZodArray,
ZodEffects,
ZodNumber,
ZodObject,
ZodString,
ZodType,
ZodTypeAny,
} from "zod";
type InputType<DefaultType extends ZodTypeAny> = {
(): ZodEffects<DefaultType>;
<ProvidedType extends ZodTypeAny>(
schema: ProvidedType
): ZodEffects<ProvidedType>;
};
const stripEmpty = z.literal("").transform(() => undefined);
const preprocessIfValid = (schema: ZodTypeAny) => (val: unknown) => {
const result = schema.safeParse(val);
if (result.success) return result.data;
return val;
};
/**
* Transforms any empty strings to `undefined` before validating.
* This makes it so empty strings will fail required checks,
* allowing you to use `optional` for optional fields instead of `nonempty` for required fields.
* If you call `zfd.text` with no arguments, it will assume the field is a required string by default.
* If you want to customize the schema, you can pass that as an argument.
*/
export const text: InputType<ZodString> = (schema = z.string()) =>
z.preprocess(preprocessIfValid(stripEmpty), schema) as any;
/**
* Coerces numerical strings to numbers transforms empty strings to `undefined` before validating.
* If you call `zfd.number` with no arguments,
* it will assume the field is a required number by default.
* If you want to customize the schema, you can pass that as an argument.
*/
export const numeric: InputType<ZodNumber> = (schema = z.number()) =>
z.preprocess(
preprocessIfValid(
z.union([
stripEmpty,
z
.string()
.transform((val) => Number(val))
.refine((val) => !Number.isNaN(val)),
])
),
schema
) as any;
type CheckboxOpts = {
trueValue?: string;
};
/**
* Turns the value from a checkbox field into a boolean,
* but does not require the checkbox to be checked.
* For checkboxes with a `value` attribute, you can pass that as the `trueValue` option.
*
* @example
* ```ts
* const schema = zfd.formData({
* defaultCheckbox: zfd.checkbox(),
* checkboxWithValue: zfd.checkbox({ trueValue: "true" }),
* mustBeTrue: zfd
* .checkbox()
* .refine((val) => val, "Please check this box"),
* });
* });
* ```
*/
export const checkbox = ({ trueValue = "on" }: CheckboxOpts = {}) =>
z.union([
z.literal(trueValue).transform(() => true),
z.literal(undefined).transform(() => false),
]);
export const file: InputType<z.ZodType<File>> = (schema = z.instanceof(File)) =>
z.preprocess((val) => {
//Empty File object on no user input, so convert to undefined
return val instanceof File && val.size === 0 ? undefined : val;
}, schema) as any;
/**
* Preprocesses a field where you expect multiple values could be present for the same field name
* and transforms the value of that field to always be an array.
* If you don't provide a schema, it will assume the field is an array of zfd.text fields
* and will not require any values to be present.
*/
export const repeatable: InputType<ZodArray<any>> = (
schema = z.array(text())
) => {
return z.preprocess((val) => {
if (Array.isArray(val)) return val;
if (val === undefined) return [];
return [val];
}, schema) as any;
};
/**
* A convenience wrapper for repeatable.
* Instead of passing the schema for an entire array, you pass in the schema for the item type.
*/
export const repeatableOfType = <T extends ZodTypeAny>(
schema: T
): ZodEffects<ZodArray<T>> => repeatable(z.array(schema));
const entries = z.array(z.tuple([z.string(), z.any()]));
type FormDataLikeInput = {
[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>;
entries(): IterableIterator<[string, FormDataEntryValue]>;
};
type FormDataType = {
<T extends z.ZodRawShape>(shape: T): ZodEffects<
ZodObject<T>,
z.output<ZodObject<T>>,
FormData | FormDataLikeInput
>;
<T extends z.ZodTypeAny>(schema: T): ZodEffects<
T,
z.output<T>,
FormData | FormDataLikeInput
>;
};
const safeParseJson = (jsonString: string) => {
try {
return JSON.parse(jsonString);
} catch {
return jsonString;
}
};
export const json = <T extends ZodTypeAny>(schema: T): ZodEffects<T> =>
z.preprocess(
preprocessIfValid(
z.union([stripEmpty, z.string().transform((val) => safeParseJson(val))])
),
schema
);
const processFormData = preprocessIfValid(
// We're avoiding using `instanceof` here because different environments
// won't necessarily have `FormData` or `URLSearchParams`
z
.any()
.refine((val) => Symbol.iterator in val)
.transform((val) => [...val])
.refine(
(val): val is z.infer<typeof entries> => entries.safeParse(val).success
)
.transform((data): Record<string, unknown | unknown[]> => {
const map: Map<string, unknown[]> = new Map();
for (const [key, value] of data) {
if (map.has(key)) {
map.get(key)!.push(value);
} else {
map.set(key, [value]);
}
}
return [...map.entries()].reduce((acc, [key, value]) => {
return setPath(acc, key, value.length === 1 ? value[0] : value);
}, {} as Record<string, unknown | unknown[]>);
})
);
export const preprocessFormData = processFormData as (
formData: unknown
) => Record<string, unknown>;
/**
* This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
* and transforms it into a regular object.
* If the `FormData` contains multiple entries with the same field name,
* it will automatically turn that field into an array.
*/
export const formData: FormDataType = (shapeOrSchema: any): any =>
z.preprocess(
processFormData,
shapeOrSchema instanceof ZodType ? shapeOrSchema : z.object(shapeOrSchema)
);