Skip to content

feat!: use zod v4 #10922

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion packages/builders/__tests__/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { describe, test, expect } from 'vitest';
import { enableValidators, disableValidators, isValidationEnabled, normalizeArray } from '../src/index.js';
import { z } from 'zod/v4';
import {
enableValidators,
disableValidators,
isValidationEnabled,
normalizeArray,
ValidationError,
} from '../src/index.js';
import { validate } from '../src/util/validation.js';

describe('validation', () => {
test('enables validation', () => {
Expand All @@ -11,6 +19,17 @@ describe('validation', () => {
disableValidators();
expect(isValidationEnabled()).toBeFalsy();
});

test('validation error', () => {
try {
validate(z.never(), 1, true);
throw new Error('validation should have failed');
} catch (error) {
expect(error).toBeInstanceOf(ValidationError);
expect((error as ValidationError).message).toBe('✖ Invalid input: expected never, received number');
expect((error as ValidationError).cause).toBeInstanceOf(z.ZodError);
}
});
});

describe('normalizeArray', () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/builders/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@
"discord-api-types": "^0.38.1",
"ts-mixer": "^6.0.4",
"tslib": "^2.8.1",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
"zod": "^3.25.57"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
Expand Down
22 changes: 7 additions & 15 deletions packages/builders/src/Assertions.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
import { Locale } from 'discord-api-types/v10';
import { z } from 'zod';
import { z } from 'zod/v4';

export const customIdPredicate = z.string().min(1).max(100);

export const memberPermissionsPredicate = z.coerce.bigint();

export const localeMapPredicate = z
.object(
Object.fromEntries(Object.values(Locale).map((loc) => [loc, z.string().optional()])) as Record<
Locale,
z.ZodOptional<z.ZodString>
>,
)
.strict();

export const refineURLPredicate = (allowedProtocols: string[]) => (value: string) => {
// eslint-disable-next-line n/prefer-global/url
const url = new URL(value);
return allowedProtocols.includes(url.protocol);
};
export const localeMapPredicate = z.strictObject(
Object.fromEntries(Object.values(Locale).map((loc) => [loc, z.string().optional()])) as Record<
Locale,
z.ZodOptional<z.ZodString>
>,
);
81 changes: 36 additions & 45 deletions packages/builders/src/components/Assertions.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate, refineURLPredicate } from '../Assertions.js';
import { z } from 'zod/v4';
import { customIdPredicate } from '../Assertions.js';

const labelPredicate = z.string().min(1).max(80);

export const emojiPredicate = z
.object({
.strictObject({
id: z.string().optional(),
name: z.string().min(2).max(32).optional(),
animated: z.boolean().optional(),
})
.strict()
.refine((data) => data.id !== undefined || data.name !== undefined, {
message: "Either 'id' or 'name' must be provided",
error: "Either 'id' or 'name' must be provided",
});

const buttonPredicateBase = z.object({
const buttonPredicateBase = z.strictObject({
type: z.literal(ComponentType.Button),
disabled: z.boolean().optional(),
});
Expand All @@ -26,31 +25,22 @@ const buttonCustomIdPredicateBase = buttonPredicateBase.extend({
label: labelPredicate,
});

const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) }).strict();
const buttonSecondaryPredicate = buttonCustomIdPredicateBase
.extend({ style: z.literal(ButtonStyle.Secondary) })
.strict();
const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) }).strict();
const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) }).strict();
const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) });
const buttonSecondaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Secondary) });
const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) });
const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) });

const buttonLinkPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Link),
url: z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:', 'discord:'])),
emoji: emojiPredicate.optional(),
label: labelPredicate,
})
.strict();
const buttonLinkPredicate = buttonPredicateBase.extend({
style: z.literal(ButtonStyle.Link),
url: z.url({ protocol: /^(?:https?|discord)$/ }),
emoji: emojiPredicate.optional(),
label: labelPredicate,
});

const buttonPremiumPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Premium),
sku_id: z.string(),
})
.strict();
const buttonPremiumPredicate = buttonPredicateBase.extend({
style: z.literal(ButtonStyle.Premium),
sku_id: z.string(),
});

export const buttonPredicate = z.discriminatedUnion('style', [
buttonLinkPredicate,
Expand All @@ -71,7 +61,7 @@ const selectMenuBasePredicate = z.object({

export const selectMenuChannelPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.ChannelSelect),
channel_types: z.nativeEnum(ChannelType).array().optional(),
channel_types: z.enum(ChannelType).array().optional(),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Channel) })
.array()
Expand All @@ -84,7 +74,7 @@ export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({
default_values: z
.object({
id: z.string(),
type: z.union([z.literal(SelectMenuDefaultValueType.Role), z.literal(SelectMenuDefaultValueType.User)]),
type: z.literal([SelectMenuDefaultValueType.Role, SelectMenuDefaultValueType.User]),
})
.array()
.max(25)
Expand Down Expand Up @@ -113,23 +103,25 @@ export const selectMenuStringPredicate = selectMenuBasePredicate
type: z.literal(ComponentType.StringSelect),
options: selectMenuStringOptionPredicate.array().min(1).max(25),
})
.superRefine((menu, ctx) => {
.check((ctx) => {
const addIssue = (name: string, minimum: number) =>
ctx.addIssue({
ctx.issues.push({
code: 'too_small',
message: `The number of options must be greater than or equal to ${name}`,
inclusive: true,
minimum,
type: 'number',
path: ['options'],
origin: 'number',
input: minimum,
});

if (menu.max_values !== undefined && menu.options.length < menu.max_values) {
addIssue('max_values', menu.max_values);
if (ctx.value.max_values !== undefined && ctx.value.options.length < ctx.value.max_values) {
addIssue('max_values', ctx.value.max_values);
}

if (menu.min_values !== undefined && menu.options.length < menu.min_values) {
addIssue('min_values', menu.min_values);
if (ctx.value.min_values !== undefined && ctx.value.options.length < ctx.value.min_values) {
addIssue('min_values', ctx.value.min_values);
}
});

Expand All @@ -152,14 +144,13 @@ export const actionRowPredicate = z.object({
.max(5),
z
.object({
type: z.union([
z.literal(ComponentType.ChannelSelect),
z.literal(ComponentType.MentionableSelect),
z.literal(ComponentType.RoleSelect),
z.literal(ComponentType.StringSelect),
z.literal(ComponentType.UserSelect),
// And this!
z.literal(ComponentType.TextInput),
type: z.literal([
ComponentType.ChannelSelect,
ComponentType.MentionableSelect,
ComponentType.StringSelect,
ComponentType.RoleSelect,
ComponentType.TextInput,
ComponentType.UserSelect,
]),
})
.array()
Expand Down
4 changes: 2 additions & 2 deletions packages/builders/src/components/textInput/Assertions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { z } from 'zod';
import { z } from 'zod/v4';
import { customIdPredicate } from '../../Assertions.js';

export const textInputPredicate = z.object({
type: z.literal(ComponentType.TextInput),
custom_id: customIdPredicate,
label: z.string().min(1).max(45),
style: z.nativeEnum(TextInputStyle),
style: z.enum(TextInputStyle),
min_length: z.number().min(0).max(4_000).optional(),
max_length: z.number().min(1).max(4_000).optional(),
placeholder: z.string().max(100).optional(),
Expand Down
21 changes: 5 additions & 16 deletions packages/builders/src/components/v2/Assertions.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10';
import { z } from 'zod';
import { refineURLPredicate } from '../../Assertions.js';
import { z } from 'zod/v4';
import { actionRowPredicate } from '../Assertions.js';

const unfurledMediaItemPredicate = z.object({
url: z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:', 'attachment:']), {
message: 'Invalid protocol for media URL. Must be http:, https:, or attachment:',
}),
url: z.url({ protocol: /^(?:https?|attachment)$/ }),
});

export const thumbnailPredicate = z.object({
Expand All @@ -19,12 +13,7 @@ export const thumbnailPredicate = z.object({
});

const unfurledMediaItemAttachmentOnlyPredicate = z.object({
url: z
.string()
.url()
.refine(refineURLPredicate(['attachment:']), {
message: 'Invalid protocol for file URL. Must be attachment:',
}),
url: z.url({ protocol: /^attachment$/ }),
});

export const filePredicate = z.object({
Expand All @@ -34,7 +23,7 @@ export const filePredicate = z.object({

export const separatorPredicate = z.object({
divider: z.boolean().optional(),
spacing: z.nativeEnum(SeparatorSpacingSize).optional(),
spacing: z.enum(SeparatorSpacingSize).optional(),
});

export const textDisplayPredicate = z.object({
Expand Down Expand Up @@ -74,5 +63,5 @@ export const containerPredicate = z.object({
.min(1)
.max(10),
spoiler: z.boolean().optional(),
accent_color: z.number().int().min(0).max(0xffffff).nullish(),
accent_color: z.int().min(0).max(0xffffff).nullish(),
});
1 change: 1 addition & 0 deletions packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export * from './util/componentUtil.js';
export * from './util/normalizeArray.js';
export * from './util/resolveBuilder.js';
export { disableValidators, enableValidators, isValidationEnabled } from './util/validation.js';
export * from './util/ValidationError.js';

export * from './Assertions.js';

Expand Down
Loading