refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)
This commit is contained in:
+6
-6
@@ -247,16 +247,16 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
|
||||
return children.filter(isHTMLElement);
|
||||
};
|
||||
|
||||
export const recursivelyMergeObjectsNotArrays = <T>(target: T, src1: T, src2: T): T => {
|
||||
return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined));
|
||||
export const recursivelyMergeObjectsNotArrays = <T>(
|
||||
...srcs: (Partial<T> | undefined | null)[]
|
||||
): T => {
|
||||
return mergeWith({}, ...srcs, (_a, b) => (Array.isArray(b) ? b : undefined));
|
||||
};
|
||||
|
||||
export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>(
|
||||
target: T,
|
||||
src1: T,
|
||||
src2: T,
|
||||
...srcs: (Partial<T> | undefined | null)[]
|
||||
): T => {
|
||||
return mergeWith(target, src1, src2, (a, b) =>
|
||||
return mergeWith({}, ...srcs, (a, b) =>
|
||||
Array.isArray(a) ? uniq(a.concat(b)) : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Recursively remove defaults from a zod schema.
|
||||
*
|
||||
* See: https://github.com/colinhacks/zod/discussions/845#discussioncomment-1936943
|
||||
*
|
||||
* @param schema The Zod schema.
|
||||
* @returns A new Zod schema.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function deepRemoveDefaults<T extends z.ZodTypeAny>(schema: T): any {
|
||||
if (schema instanceof z.ZodDefault) {
|
||||
return deepRemoveDefaults(schema.removeDefault());
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const newShape = {};
|
||||
|
||||
for (const key in schema.shape) {
|
||||
const fieldSchema = schema.shape[key];
|
||||
newShape[key] = z.ZodOptional.create(deepRemoveDefaults(fieldSchema));
|
||||
}
|
||||
return new z.ZodObject({
|
||||
...schema._def,
|
||||
shape: () => newShape,
|
||||
});
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return z.ZodArray.create(deepRemoveDefaults(schema.element))
|
||||
.min(schema._def.minLength?.value, schema._def.minLength?.message)
|
||||
.max(schema._def.maxLength?.value, schema._def.maxLength?.message)
|
||||
.length(schema._def.exactLength?.value, schema._def.exactLength?.message);
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodOptional) {
|
||||
return z.ZodOptional.create(deepRemoveDefaults(schema.unwrap()));
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodNullable) {
|
||||
return z.ZodNullable.create(deepRemoveDefaults(schema.unwrap()));
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodTuple) {
|
||||
return z.ZodTuple.create(
|
||||
schema.items.map((item: z.ZodTypeAny) => deepRemoveDefaults(item)),
|
||||
);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the keys that didn't parse from a ZodError.
|
||||
* @param error The zoderror to extract the keys from.
|
||||
* @returns An array of error keys.
|
||||
*/
|
||||
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
|
||||
const errors = error.format();
|
||||
return Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration parse errors.
|
||||
* @param error The ZodError object from parsing.
|
||||
* @returns An array of string error paths.
|
||||
*/
|
||||
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> => {
|
||||
/* Zod errors involving unions are complex, as Zod may not be able to tell
|
||||
* where the 'real' error is vs simply a union option not matching. This
|
||||
* function finds all ZodError "issues" that don't have an error with 'type'
|
||||
* in that object ('type' is the union discriminator for picture elements,
|
||||
* the major union in the schema). An array of user-readable error
|
||||
* locations is returned, or an empty list if none is available. None being
|
||||
* available suggests the configuration has an error, but we can't tell
|
||||
* exactly why (or rather Zod simply says it doesn't match any of the
|
||||
* available unions). This usually suggests the user specified an incorrect
|
||||
* type name entirely. */
|
||||
const contenders = new Set<string>();
|
||||
if (error.issues.length) {
|
||||
for (const issue of error.issues) {
|
||||
if (issue.code === 'invalid_union') {
|
||||
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
|
||||
for (const unionError of unionErrors) {
|
||||
getParseErrorPaths(unionError).forEach(contenders.add, contenders);
|
||||
}
|
||||
} else {
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
return contenders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an array of strings and indices into a more user readable string,
|
||||
* e.g. [a, 1, b, 2] => 'a[1] -> b[2]'
|
||||
* @param path An array of strings and numbers.
|
||||
* @returns A single string.
|
||||
*/
|
||||
const getParseErrorPathString = (path: (string | number)[]): string => {
|
||||
let out = '';
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const item = path[i];
|
||||
if (typeof item == 'number') {
|
||||
out += '[' + item + ']';
|
||||
} else if (out) {
|
||||
out += ' -> ' + item;
|
||||
} else {
|
||||
out = item;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* This utility intentionally supports classic/full Zod schemas only
|
||||
* (i.e. schemas created via `import { z } from 'zod'`).
|
||||
* It does not target `zod/mini` schema instances.
|
||||
*
|
||||
* In Zod 4, internal accessors (.shape values, .unwrap() results, etc.)
|
||||
* return core.$ZodType instead of the classic ZodType.
|
||||
*/
|
||||
const toClassic = (schema: z.ZodType | z.core.$ZodType): z.ZodType => {
|
||||
if (schema instanceof z.ZodType) {
|
||||
return schema;
|
||||
}
|
||||
throw new TypeError('deepRemoveDefaults supports full zod schemas only');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether an object field originally had a default/prefault wrapper,
|
||||
* meaning it should become optional after stripping. Walks through
|
||||
* transparent wrappers (nullable, readonly, etc.) to find defaults.
|
||||
*/
|
||||
function fieldWasDefaulted(schema: z.ZodType, seen = new Set<z.ZodType>()): boolean {
|
||||
if (seen.has(schema)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(schema);
|
||||
|
||||
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
|
||||
return true;
|
||||
}
|
||||
if (schema instanceof z.ZodOptional) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk through transparent wrappers.
|
||||
if (schema instanceof z.ZodNullable) {
|
||||
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
|
||||
}
|
||||
if (schema instanceof z.ZodReadonly) {
|
||||
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
|
||||
}
|
||||
if (schema instanceof z.ZodNonOptional) {
|
||||
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
|
||||
}
|
||||
if (schema instanceof z.ZodLazy) {
|
||||
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
|
||||
}
|
||||
if (schema instanceof z.ZodPipe) {
|
||||
return fieldWasDefaulted(toClassic(schema.in), seen);
|
||||
}
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
return [...schema.options].some((option) =>
|
||||
fieldWasDefaulted(toClassic(option), seen),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core recursive implementation. Strips all default/prefault wrappers
|
||||
* and makes previously-defaulted object fields optional instead.
|
||||
*/
|
||||
function strip(schema: z.ZodType, cache: Map<z.ZodType, z.ZodType>): z.ZodType {
|
||||
const cached = cache.get(schema);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Seed the cache with a forward reference before recursing so cycles
|
||||
// (including getter-based recursive objects) do not overflow the stack.
|
||||
const reference: { schema: z.ZodType } = { schema };
|
||||
const forward = z.lazy(() => reference.schema);
|
||||
cache.set(schema, forward);
|
||||
|
||||
let result: z.ZodType;
|
||||
|
||||
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
|
||||
// Unwrap the default — don't cache the wrapper itself.
|
||||
result = strip(toClassic(schema.unwrap()), cache);
|
||||
} else if (schema instanceof z.ZodObject) {
|
||||
const newShape: Record<string, z.core.$ZodType> = {};
|
||||
for (const [key, field] of Object.entries(schema.shape)) {
|
||||
const classicField = toClassic(field);
|
||||
const stripped = strip(classicField, cache);
|
||||
const makeOptional =
|
||||
fieldWasDefaulted(classicField) && !(stripped instanceof z.ZodOptional);
|
||||
newShape[key] = makeOptional ? stripped.optional() : stripped;
|
||||
}
|
||||
result = z.clone(schema, { ...schema.def, shape: newShape });
|
||||
} else if (schema instanceof z.ZodArray) {
|
||||
result = z.clone(schema, {
|
||||
...schema.def,
|
||||
element: strip(toClassic(schema.element), cache),
|
||||
});
|
||||
} else if (schema instanceof z.ZodTuple) {
|
||||
result = z.clone(schema, {
|
||||
...schema.def,
|
||||
items: schema.def.items.map((item) => strip(toClassic(item), cache)),
|
||||
rest: schema.def.rest ? strip(toClassic(schema.def.rest), cache) : null,
|
||||
});
|
||||
} else if (schema instanceof z.ZodUnion) {
|
||||
const options = [...schema.options].map((opt) => strip(toClassic(opt), cache));
|
||||
result = z.clone(schema, { ...schema.def, options });
|
||||
} else if (schema instanceof z.ZodLazy) {
|
||||
result = z.lazy(() => strip(toClassic(schema.unwrap()), cache));
|
||||
} else if (schema instanceof z.ZodPipe) {
|
||||
result = z.clone(schema, {
|
||||
...schema.def,
|
||||
in: strip(toClassic(schema.in), cache),
|
||||
out: strip(toClassic(schema.out), cache),
|
||||
});
|
||||
} else if (
|
||||
schema instanceof z.ZodOptional ||
|
||||
schema instanceof z.ZodNullable ||
|
||||
schema instanceof z.ZodReadonly ||
|
||||
schema instanceof z.ZodNonOptional ||
|
||||
schema instanceof z.ZodCatch ||
|
||||
schema instanceof z.ZodSuccess ||
|
||||
schema instanceof z.ZodPromise
|
||||
) {
|
||||
// All of these are single-child wrappers with .unwrap().
|
||||
result = z.clone(schema, {
|
||||
...schema.def,
|
||||
innerType: strip(toClassic(schema.unwrap()), cache),
|
||||
});
|
||||
} else {
|
||||
// Leaf types (string, number, boolean, enum, literal, etc.).
|
||||
result = schema;
|
||||
}
|
||||
|
||||
reference.schema = result;
|
||||
cache.set(schema, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively strips `z.default()` and `z.prefault()` wrappers from a schema.
|
||||
* Object fields that had defaults become optional instead.
|
||||
* Uses a cache to safely handle recursive (z.lazy) schemas.
|
||||
*/
|
||||
export function deepRemoveDefaults<T extends z.ZodType>(
|
||||
schema: T,
|
||||
cache = new Map<z.ZodType, z.ZodType>(),
|
||||
): T {
|
||||
return strip(schema, cache) as T;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Get configuration parse errors.
|
||||
* @param error The ZodError object from parsing.
|
||||
* @returns A set of string error paths.
|
||||
*/
|
||||
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> => {
|
||||
/* Zod errors involving unions are complex, as Zod may not be able to tell
|
||||
* where the 'real' error is vs simply a union option not matching. This
|
||||
* function recursively extracts all error paths from all branches of a union.
|
||||
* It returns a Set of dot-notation strings. If no paths are found, it suggests
|
||||
* the configuration has an error but Zod cannot tell exactly why (usually an
|
||||
* entirely incorrect type name). */
|
||||
const contenders = new Set<string>();
|
||||
if (error.issues.length) {
|
||||
for (const issue of error.issues) {
|
||||
if (issue.code === 'invalid_union') {
|
||||
const unionErrors = (issue as z.core.$ZodIssueInvalidUnion).errors;
|
||||
for (const issues of unionErrors) {
|
||||
const nestedPaths = getParseErrorPaths(new z.ZodError(issues));
|
||||
const prefix = z.core.toDotPath(issue.path);
|
||||
nestedPaths.forEach((path) => {
|
||||
contenders.add(prefix ? `${prefix}.${path}` : path);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
contenders.add(z.core.toDotPath(issue.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
return contenders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration parse errors.
|
||||
* @param error The ZodError object from parsing.
|
||||
* @returns A string error message or null.
|
||||
*/
|
||||
export const getParseError = <T>(error: z.ZodError<T>): string | null => {
|
||||
const paths = getParseErrorPaths(error);
|
||||
return paths.size === 0 ? null : JSON.stringify([...paths], null, ' ');
|
||||
};
|
||||
Reference in New Issue
Block a user