Skip to content

Commit

Permalink
Merge pull request #5743 from NomicFoundation/zod-conditionalUnionType
Browse files Browse the repository at this point in the history
Introduce the Zod `conditionalUnionType` helper
  • Loading branch information
alcuadrado authored Sep 13, 2024
2 parents 2de8407 + f1b0640 commit 2d0d359
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 19 deletions.
4 changes: 2 additions & 2 deletions config-v-next/eslint.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ function createConfig(
selector:
"CallExpression[callee.object.name='z'][callee.property.name=union]",
message:
"Use the unionType helper from the zod utils package instead, as it provides better error messages.",
"Use the conditionalUnionType or unionType helpers from the zod utils package instead, as it provides better error messages.",
},
],
"@typescript-eslint/restrict-plus-operands": "error",
Expand Down Expand Up @@ -398,7 +398,7 @@ function createConfig(
name: "zod",
importNames: ["union"],
message:
"Use the unionType helper from the zod utils package instead, as it provides better error messages.",
"Use the conditionalUnionType or unionType helpers from the zod utils package instead, as it provides better error messages.",
},
],
},
Expand Down
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions v-next/hardhat-mocha-test-runner/src/hookHandlers/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ConfigHooks } from "@ignored/hardhat-vnext/types/hooks";

import { isObject } from "@ignored/hardhat-vnext-utils/lang";
import { resolveFromRoot } from "@ignored/hardhat-vnext-utils/path";
import {
conditionalUnionType,
unionType,
validateUserConfigZodType,
} from "@ignored/hardhat-vnext-zod-utils";
Expand Down Expand Up @@ -72,10 +74,17 @@ const mochaConfigType = z.object({

const userConfigType = z.object({
mocha: z.optional(mochaConfigType),
test: unionType(
[z.object({ mocha: z.string().optional() }), z.string()],
"Expected a string or an object with an optional 'mocha' property",
).optional(),
paths: z
.object({
test: conditionalUnionType(
[
[(data) => typeof data === "string", z.string()],
[isObject, z.object({ mocha: z.string().optional() })],
],
"Expected a string or an object with an optional 'mocha' property",
).optional(),
})
.optional(),
});

export default async (): Promise<Partial<ConfigHooks>> => {
Expand Down
18 changes: 13 additions & 5 deletions v-next/hardhat-node-test-runner/src/hookHandlers/config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import type { ConfigHooks } from "@ignored/hardhat-vnext/types/hooks";

import { isObject } from "@ignored/hardhat-vnext-utils/lang";
import { resolveFromRoot } from "@ignored/hardhat-vnext-utils/path";
import {
unionType,
conditionalUnionType,
validateUserConfigZodType,
} from "@ignored/hardhat-vnext-zod-utils";
import { z } from "zod";

const userConfigType = z.object({
test: unionType(
[z.object({ nodeTest: z.string().optional() }), z.string()],
"Expected a string or an object with an optional 'nodeTest' property",
).optional(),
paths: z
.object({
test: conditionalUnionType(
[
[isObject, z.object({ nodeTest: z.string().optional() })],
[(data) => typeof data === "string", z.string()],
],
"Expected a string or an object with an optional 'nodeTest' property",
).optional(),
})
.optional(),
});

export default async (): Promise<Partial<ConfigHooks>> => {
Expand Down
3 changes: 3 additions & 0 deletions v-next/hardhat-zod-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"typescript-eslint": "7.7.1",
"zod": "^3.23.8"
},
"dependencies": {
"@ignored/hardhat-vnext-utils": "workspace:^3.0.0-next.2"
},
"peerDependencies": {
"zod": "^3.23.8"
}
Expand Down
124 changes: 117 additions & 7 deletions v-next/hardhat-zod-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ZodTypeDef, ZodType } from "zod";

import { isObject } from "@ignored/hardhat-vnext-utils/lang";
import { z } from "zod";

/**
Expand All @@ -19,6 +20,14 @@ export interface HardhatUserConfigValidationError {
/**
* A Zod untagged union type that returns a custom error message if the value
* is missing or invalid.
*
* WARNING: In most cases you should use {@link conditionalUnionType} instead.
*
* This union type is valid for simple cases, where the union is made of
* primitive or simple types.
*
* If you have a type that's complex, like an object or array, you must use
* {@link conditionalUnionType}.
*/
export const unionType = (
types: Parameters<typeof z.union>[0],
Expand All @@ -31,6 +40,101 @@ export const unionType = (
}),
});

/**
* A Zod union type that allows you to provide hints to Zod about which of the
* type variant it should use.
*
* It receives an array of tuples, where each tuple contains a predicate
* function and a ZodType. The predicate function takes the data to be parsed
* and returns a boolean. If the predicate function returns true, the ZodType
* is used to parse the data.
*
* If none of the predicates returns true, an error is added to the context
* with the noMatchMessage message.
*
* For example, you can use this to conditionally validate a union type based
* on the values `typeof` and its fields:
*
* @example
* ```ts
* const fooType = conditionalUnionType(
* [
* [(data) => typeof data === "string", z.string()],
* [(data) => Array.isArray(data), z.array(z.string()).nonempty()],
* [(data) => isObject(data), z.object({foo: z.string().optional()})]
* ],
* "Expected a string, an array of strings, or an object with an optional 'foo' property",
* );
* ```
*
* @param cases An array of tuples of a predicate function and a ZodType.
* @param noMatchMessage THe error message to return if none of the predicates
* returns true.
* @returns The conditional union ZodType.
*/
export const conditionalUnionType = (
cases: Array<[predicate: (data: unknown) => boolean, zodType: ZodType<any>]>,
noMatchMessage: string,
) =>
z.any().superRefine((data, ctx) => {
const matchingCase = cases.find(([predicate]) => predicate(data));
if (matchingCase === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: noMatchMessage,
});
return;
}

const zodeType = matchingCase[1];

const parsedData = zodeType.safeParse(data);
if (parsedData.error !== undefined) {
for (const issue of parsedData.error.issues) {
ctx.addIssue(issue);
}
}
});

/**
* Creates a Zod type to validate that a field of an object doesn't exist.
*
* This is useful when you have a {@link conditionalUnionType} that represents
* a union of object types with incompatible fields between each other.
*
* @example
* ```ts
* const typeWithFoo = z.object({
* foo: z.string(),
* bar: unexpectedFieldType("This field is incompatible with `foo`"),
* });
*
* const typeWithBar = z.object({
* bar: z.string(),
* foo: unexpectedFieldType("This field is incompatible with `bar`"),
* });
*
* const union = conditionalUnionType(
* [
* [(data) => isObject(data) && "foo" in data, typeWithFoo],
* [(data) => isObject(data) && "bar" in data, typeWithBar],
* ],
* "Expected an object with either a `foo` or a `bar` field",
* );
* ```
*
* @param errorMessage The error message to display if the field is present.
* @returns A Zod type that validates that a field of an object doesn't exist.
*/
export const incompatibleFieldType = (errorMessage = "Unexpected field") =>
z
.never({
errorMap: () => ({
message: errorMessage,
}),
})
.optional();

/**
* A Zod type to validate Hardhat's ConfigurationVariable objects.
*/
Expand All @@ -42,16 +146,22 @@ export const configurationVariableType = z.object({
/**
* A Zod type to validate Hardhat's SensitiveString values.
*/
export const sensitiveStringType = unionType(
[z.string(), configurationVariableType],
export const sensitiveStringType = conditionalUnionType(
[
[(data) => typeof data === "string", z.string()],
[isObject, configurationVariableType],
],
"Expected a string or a Configuration Variable",
);

/**
* A Zod type to validate Hardhat's SensitiveString values that expect a URL.
*/
export const sensitiveUrlType = unionType(
[z.string().url(), configurationVariableType],
export const sensitiveUrlType = conditionalUnionType(
[
[(data) => typeof data === "string", z.string().url()],
[isObject, configurationVariableType],
],
"Expected a URL or a Configuration Variable",
);

Expand All @@ -64,15 +174,15 @@ export const sensitiveUrlType = unionType(
* from the root of the config object, so that they are correctly reported to
* the user.
*/
export async function validateUserConfigZodType<
export function validateUserConfigZodType<
Output,
Def extends ZodTypeDef = ZodTypeDef,
Input = Output,
>(
hardhatUserConfig: HardhatUserConfigToValidate,
configType: ZodType<Output, Def, Input>,
): Promise<HardhatUserConfigValidationError[]> {
const result = await configType.safeParseAsync(hardhatUserConfig);
): HardhatUserConfigValidationError[] {
const result = configType.safeParse(hardhatUserConfig);

if (result.success) {
return [];
Expand Down
Loading

0 comments on commit 2d0d359

Please sign in to comment.