refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)

This commit is contained in:
Dermot Duffy
2026-02-18 21:47:43 -08:00
committed by GitHub
parent a15376602f
commit 529500ed94
51 changed files with 1110 additions and 443 deletions
+6 -6
View File
@@ -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,
);
};
-114
View File
@@ -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;
};
+147
View File
@@ -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;
}
+43
View File
@@ -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, ' ');
};