diff --git a/packages/builders/__tests__/util.test.ts b/packages/builders/__tests__/util.test.ts index 1a90e3acf..809e89981 100644 --- a/packages/builders/__tests__/util.test.ts +++ b/packages/builders/__tests__/util.test.ts @@ -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', () => { @@ -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', () => { diff --git a/packages/builders/package.json b/packages/builders/package.json index 401c6071f..b4f9d9290 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -69,8 +69,7 @@ "discord-api-types": "^0.38.14", "ts-mixer": "^6.0.4", "tslib": "^2.8.1", - "zod": "^3.24.2", - "zod-validation-error": "^3.4.0" + "zod": "^3.25.69" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/builders/src/Assertions.ts b/packages/builders/src/Assertions.ts index 29fb2cd62..2e24b150d 100644 --- a/packages/builders/src/Assertions.ts +++ b/packages/builders/src/Assertions.ts @@ -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 - >, - ) - .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 + >, +); diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 4b8c02066..a85da2395 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -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(), }); @@ -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, @@ -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() @@ -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) @@ -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); } }); @@ -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() diff --git a/packages/builders/src/components/textInput/Assertions.ts b/packages/builders/src/components/textInput/Assertions.ts index 0e6dc7ed7..98e71e1b5 100644 --- a/packages/builders/src/components/textInput/Assertions.ts +++ b/packages/builders/src/components/textInput/Assertions.ts @@ -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(), diff --git a/packages/builders/src/components/v2/Assertions.ts b/packages/builders/src/components/v2/Assertions.ts index ea1d645f7..dbc2225f1 100644 --- a/packages/builders/src/components/v2/Assertions.ts +++ b/packages/builders/src/components/v2/Assertions.ts @@ -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({ @@ -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({ @@ -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({ @@ -73,5 +62,5 @@ export const containerPredicate = z.object({ ) .min(1), spoiler: z.boolean().optional(), - accent_color: z.number().int().min(0).max(0xffffff).nullish(), + accent_color: z.int().min(0).max(0xffffff).nullish(), }); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 73ce18e3e..8ad98d448 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -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'; diff --git a/packages/builders/src/interactions/commands/chatInput/Assertions.ts b/packages/builders/src/interactions/commands/chatInput/Assertions.ts index e0f9dd009..66d73008a 100644 --- a/packages/builders/src/interactions/commands/chatInput/Assertions.ts +++ b/packages/builders/src/interactions/commands/chatInput/Assertions.ts @@ -3,8 +3,7 @@ import { InteractionContextType, ApplicationCommandOptionType, } from 'discord-api-types/v10'; -import type { ZodTypeAny } from 'zod'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; import { ApplicationCommandOptionAllowedChannelTypes } from './mixins/ApplicationCommandOptionChannelTypesMixin.js'; @@ -24,26 +23,17 @@ const sharedNameAndDescriptionPredicate = z.object({ }); const numericMixinNumberOptionPredicate = z.object({ - max_value: z.number().safe().optional(), - min_value: z.number().safe().optional(), + max_value: z.float32().optional(), + min_value: z.float32().optional(), }); const numericMixinIntegerOptionPredicate = z.object({ - max_value: z.number().safe().int().optional(), - min_value: z.number().safe().int().optional(), + max_value: z.int().optional(), + min_value: z.int().optional(), }); const channelMixinOptionPredicate = z.object({ - channel_types: z - .union( - ApplicationCommandOptionAllowedChannelTypes.map((type) => z.literal(type)) as unknown as [ - ZodTypeAny, - ZodTypeAny, - ...ZodTypeAny[], - ], - ) - .array() - .optional(), + channel_types: z.literal(ApplicationCommandOptionAllowedChannelTypes).array().optional(), }); const autocompleteMixinOptionPredicate = z.object({ @@ -52,7 +42,7 @@ const autocompleteMixinOptionPredicate = z.object({ }); const choiceValueStringPredicate = z.string().min(1).max(100); -const choiceValueNumberPredicate = z.number().safe(); +const choiceValueNumberPredicate = z.number(); const choiceBasePredicate = z.object({ name: choiceValueStringPredicate, name_localizations: localeMapPredicate.optional(), @@ -74,7 +64,7 @@ const choiceNumberMixinPredicate = choiceBaseMixinPredicate.extend({ choices: choiceNumberPredicate.array().max(25).optional(), }); -const basicOptionTypes = [ +const basicOptionTypesPredicate = z.literal([ ApplicationCommandOptionType.Attachment, ApplicationCommandOptionType.Boolean, ApplicationCommandOptionType.Channel, @@ -84,11 +74,7 @@ const basicOptionTypes = [ ApplicationCommandOptionType.Role, ApplicationCommandOptionType.String, ApplicationCommandOptionType.User, -] as const; - -const basicOptionTypesPredicate = z.union( - basicOptionTypes.map((type) => z.literal(type)) as unknown as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]], -); +]); export const basicOptionPredicate = sharedNameAndDescriptionPredicate.extend({ required: z.boolean().optional(), @@ -105,14 +91,23 @@ const autocompleteOrNumberChoicesMixinOptionPredicate = z.discriminatedUnion('au choiceNumberMixinPredicate, ]); -export const channelOptionPredicate = basicOptionPredicate.merge(channelMixinOptionPredicate); +export const channelOptionPredicate = z.object({ + ...basicOptionPredicate.shape, + ...channelMixinOptionPredicate.shape, +}); -export const integerOptionPredicate = basicOptionPredicate - .merge(numericMixinIntegerOptionPredicate) +export const integerOptionPredicate = z + .object({ + ...basicOptionPredicate.shape, + ...numericMixinIntegerOptionPredicate.shape, + }) .and(autocompleteOrNumberChoicesMixinOptionPredicate); -export const numberOptionPredicate = basicOptionPredicate - .merge(numericMixinNumberOptionPredicate) +export const numberOptionPredicate = z + .object({ + ...basicOptionPredicate.shape, + ...numericMixinNumberOptionPredicate.shape, + }) .and(autocompleteOrNumberChoicesMixinOptionPredicate); export const stringOptionPredicate = basicOptionPredicate @@ -123,9 +118,9 @@ export const stringOptionPredicate = basicOptionPredicate .and(autocompleteOrStringChoicesMixinOptionPredicate); const baseChatInputCommandPredicate = sharedNameAndDescriptionPredicate.extend({ - contexts: z.array(z.nativeEnum(InteractionContextType)).optional(), + contexts: z.array(z.enum(InteractionContextType)).optional(), default_member_permissions: memberPermissionsPredicate.optional(), - integration_types: z.array(z.nativeEnum(ApplicationIntegrationType)).optional(), + integration_types: z.array(z.enum(ApplicationIntegrationType)).optional(), nsfw: z.boolean().optional(), }); diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts index 8d5dc77d6..ef80706ab 100644 --- a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts +++ b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts @@ -17,7 +17,7 @@ export const ApplicationCommandOptionAllowedChannelTypes = [ /** * Allowed channel types used for a channel option. */ -export type ApplicationCommandOptionAllowedChannelTypes = (typeof ApplicationCommandOptionAllowedChannelTypes)[number]; +export type ApplicationCommandOptionAllowedChannelType = (typeof ApplicationCommandOptionAllowedChannelTypes)[number]; export interface ApplicationCommandOptionChannelTypesData extends Pick {} @@ -36,7 +36,7 @@ export class ApplicationCommandOptionChannelTypesMixin { * * @param channelTypes - The channel types */ - public addChannelTypes(...channelTypes: RestOrArray) { + public addChannelTypes(...channelTypes: RestOrArray) { this.data.channel_types ??= []; this.data.channel_types.push(...normalizeArray(channelTypes)); @@ -48,7 +48,7 @@ export class ApplicationCommandOptionChannelTypesMixin { * * @param channelTypes - The channel types */ - public setChannelTypes(...channelTypes: RestOrArray) { + public setChannelTypes(...channelTypes: RestOrArray) { this.data.channel_types = normalizeArray(channelTypes); return this; } diff --git a/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts b/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts index 22dbc96ff..398917b2e 100644 --- a/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts +++ b/packages/builders/src/interactions/commands/chatInput/options/ApplicationCommandOptionBase.ts @@ -4,7 +4,7 @@ import type { APIApplicationCommandOption, ApplicationCommandOptionType, } from 'discord-api-types/v10'; -import type { z } from 'zod'; +import type { z } from 'zod/v4'; import { validate } from '../../../../util/validation.js'; import type { SharedNameAndDescriptionData } from '../../SharedNameAndDescription.js'; import { SharedNameAndDescription } from '../../SharedNameAndDescription.js'; @@ -24,7 +24,7 @@ export abstract class ApplicationCommandOptionBase /** * @internal */ - protected static readonly predicate: z.ZodTypeAny = basicOptionPredicate; + protected static readonly predicate: z.ZodType = basicOptionPredicate; /** * @internal diff --git a/packages/builders/src/interactions/commands/contextMenu/Assertions.ts b/packages/builders/src/interactions/commands/contextMenu/Assertions.ts index 909a994e7..c307a35dc 100644 --- a/packages/builders/src/interactions/commands/contextMenu/Assertions.ts +++ b/packages/builders/src/interactions/commands/contextMenu/Assertions.ts @@ -1,5 +1,5 @@ import { ApplicationCommandType, ApplicationIntegrationType, InteractionContextType } from 'discord-api-types/v10'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; const namePredicate = z @@ -8,8 +8,8 @@ const namePredicate = z .max(32) .regex(/^(?:(?: *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}\p{Extended_Pictographic}\p{Emoji_Component}]) *)+$/u); -const contextsPredicate = z.array(z.nativeEnum(InteractionContextType)); -const integrationTypesPredicate = z.array(z.nativeEnum(ApplicationIntegrationType)); +const contextsPredicate = z.array(z.enum(InteractionContextType)); +const integrationTypesPredicate = z.array(z.enum(ApplicationIntegrationType)); const baseContextMenuCommandPredicate = z.object({ contexts: contextsPredicate.optional(), diff --git a/packages/builders/src/interactions/modals/Assertions.ts b/packages/builders/src/interactions/modals/Assertions.ts index 84bf36861..9dac7d00b 100644 --- a/packages/builders/src/interactions/modals/Assertions.ts +++ b/packages/builders/src/interactions/modals/Assertions.ts @@ -1,5 +1,5 @@ import { ComponentType } from 'discord-api-types/v10'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { customIdPredicate } from '../../Assertions.js'; const titlePredicate = z.string().min(1).max(45); diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts index da2cb7911..53b82a675 100644 --- a/packages/builders/src/messages/Assertions.ts +++ b/packages/builders/src/messages/Assertions.ts @@ -1,5 +1,5 @@ import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { embedPredicate } from './embed/Assertions.js'; import { pollPredicate } from './poll/Assertions.js'; @@ -17,7 +17,7 @@ export const attachmentPredicate = z.object({ export const allowedMentionPredicate = z .object({ - parse: z.nativeEnum(AllowedMentionsTypes).array().optional(), + parse: z.enum(AllowedMentionsTypes).array().optional(), roles: z.string().array().max(100).optional(), users: z.string().array().max(100).optional(), replied_user: z.boolean().optional(), @@ -29,7 +29,7 @@ export const allowedMentionPredicate = z (data.parse?.includes(AllowedMentionsTypes.Role) && data.roles?.length) ), { - message: + error: 'Cannot specify both parse: ["users"] and non-empty users array, or parse: ["roles"] and non-empty roles array. These are mutually exclusive', }, ); @@ -39,7 +39,7 @@ export const messageReferencePredicate = z.object({ fail_if_not_exists: z.boolean().optional(), guild_id: z.string().optional(), message_id: z.string(), - type: z.nativeEnum(MessageReferenceType).optional(), + type: z.enum(MessageReferenceType).optional(), }); const baseMessagePredicate = z.object({ @@ -55,13 +55,13 @@ const basicActionRowPredicate = z.object({ type: z.literal(ComponentType.ActionRow), components: z .object({ - type: z.union([ - z.literal(ComponentType.Button), - z.literal(ComponentType.ChannelSelect), - z.literal(ComponentType.MentionableSelect), - z.literal(ComponentType.RoleSelect), - z.literal(ComponentType.StringSelect), - z.literal(ComponentType.UserSelect), + type: z.literal([ + ComponentType.Button, + ComponentType.ChannelSelect, + ComponentType.MentionableSelect, + ComponentType.RoleSelect, + ComponentType.StringSelect, + ComponentType.UserSelect, ]), }) .array(), @@ -75,15 +75,10 @@ const messageNoComponentsV2Predicate = baseMessagePredicate poll: pollPredicate.optional(), components: basicActionRowPredicate.array().max(5).optional(), flags: z - .number() + .int() .optional() - .refine((flags) => { - // If we have flags, ensure we don't have the ComponentsV2 flag - if (flags) { - return (flags & MessageFlags.IsComponentsV2) === 0; - } - - return true; + .refine((flags) => !flags || (flags & MessageFlags.IsComponentsV2) === 0, { + error: 'Cannot set content, embeds, stickers, or poll with IsComponentsV2 flag set', }), }) .refine( @@ -94,22 +89,22 @@ const messageNoComponentsV2Predicate = baseMessagePredicate (data.attachments !== undefined && data.attachments.length > 0) || (data.components !== undefined && data.components.length > 0) || (data.sticker_ids !== undefined && data.sticker_ids.length > 0), - { message: 'Messages must have content, embeds, a poll, attachments, components or stickers' }, + { error: 'Messages must have content, embeds, a poll, attachments, components or stickers' }, ); const allTopLevelComponentsPredicate = z .union([ basicActionRowPredicate, z.object({ - type: z.union([ + type: z.literal([ // Components v2 - z.literal(ComponentType.Container), - z.literal(ComponentType.File), - z.literal(ComponentType.MediaGallery), - z.literal(ComponentType.Section), - z.literal(ComponentType.Separator), - z.literal(ComponentType.TextDisplay), - z.literal(ComponentType.Thumbnail), + ComponentType.Container, + ComponentType.File, + ComponentType.MediaGallery, + ComponentType.Section, + ComponentType.Separator, + ComponentType.TextDisplay, + ComponentType.Thumbnail, ]), }), ]) @@ -119,7 +114,9 @@ const allTopLevelComponentsPredicate = z const messageComponentsV2Predicate = baseMessagePredicate.extend({ components: allTopLevelComponentsPredicate, - flags: z.number().refine((flags) => (flags & MessageFlags.IsComponentsV2) === MessageFlags.IsComponentsV2), + flags: z.int().refine((flags) => (flags & MessageFlags.IsComponentsV2) === MessageFlags.IsComponentsV2, { + error: 'Must set IsComponentsV2 flag to use Components V2', + }), // These fields cannot be set content: z.string().length(0).nullish(), embeds: z.array(z.never()).nullish(), diff --git a/packages/builders/src/messages/embed/Assertions.ts b/packages/builders/src/messages/embed/Assertions.ts index 6cac1db13..4809f61c1 100644 --- a/packages/builders/src/messages/embed/Assertions.ts +++ b/packages/builders/src/messages/embed/Assertions.ts @@ -1,20 +1,11 @@ -import { z } from 'zod'; -import { refineURLPredicate } from '../../Assertions.js'; +import { z } from 'zod/v4'; import { embedLength } from '../../util/componentUtil.js'; const namePredicate = z.string().max(256); -const URLPredicate = z - .string() - .url() - .refine(refineURLPredicate(['http:', 'https:']), { message: 'Invalid protocol for URL. Must be http: or https:' }); +const URLPredicate = z.url({ protocol: /^https?$/ }); -const URLWithAttachmentProtocolPredicate = z - .string() - .url() - .refine(refineURLPredicate(['http:', 'https:', 'attachment:']), { - message: 'Invalid protocol for URL. Must be http:, https:, or attachment:', - }); +const URLWithAttachmentProtocolPredicate = z.url({ protocol: /^(?:https?|attachment)$/ }); export const embedFieldPredicate = z.object({ name: namePredicate, @@ -39,7 +30,7 @@ export const embedPredicate = z description: z.string().min(1).max(4_096).optional(), url: URLPredicate.optional(), timestamp: z.string().optional(), - color: z.number().int().min(0).max(0xffffff).optional(), + color: z.int().min(0).max(0xffffff).optional(), footer: embedFooterPredicate.optional(), image: z.object({ url: URLWithAttachmentProtocolPredicate }).optional(), thumbnail: z.object({ url: URLWithAttachmentProtocolPredicate }).optional(), @@ -56,7 +47,7 @@ export const embedPredicate = z embed.image !== undefined || embed.thumbnail !== undefined, { - message: 'Embed must have at least a title, description, a field, a footer, an author, an image, OR a thumbnail.', + error: 'Embed must have at least a title, description, a field, a footer, an author, an image, OR a thumbnail.', }, ) - .refine((embed) => embedLength(embed) <= 6_000, { message: 'Embeds must not exceed 6000 characters in total.' }); + .refine((embed) => embedLength(embed) <= 6_000, { error: 'Embeds must not exceed 6000 characters in total.' }); diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index 769737b43..44a1b1411 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -1,5 +1,5 @@ import { PollLayoutType } from 'discord-api-types/v10'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { emojiPredicate } from '../../components/Assertions'; export const pollQuestionPredicate = z.object({ text: z.string().min(1).max(300) }); @@ -16,5 +16,5 @@ export const pollPredicate = z.object({ answers: z.array(pollAnswerPredicate).min(1).max(10), duration: z.number().min(1).max(768).optional(), allow_multiselect: z.boolean().optional(), - layout_type: z.nativeEnum(PollLayoutType).optional(), + layout_type: z.enum(PollLayoutType).optional(), }); diff --git a/packages/builders/src/util/ValidationError.ts b/packages/builders/src/util/ValidationError.ts new file mode 100644 index 000000000..b58b46ac1 --- /dev/null +++ b/packages/builders/src/util/ValidationError.ts @@ -0,0 +1,21 @@ +import { z } from 'zod/v4'; + +/** + * An error that is thrown when validation fails. + */ +export class ValidationError extends Error { + /** + * The underlying cause of the validation error. + */ + public override readonly cause: z.ZodError; + + /** + * @internal + */ + public constructor(error: z.ZodError) { + super(z.prettifyError(error)); + + this.name = 'ValidationError'; + this.cause = error; + } +} diff --git a/packages/builders/src/util/validation.ts b/packages/builders/src/util/validation.ts index 15c840930..7d6ee049a 100644 --- a/packages/builders/src/util/validation.ts +++ b/packages/builders/src/util/validation.ts @@ -1,5 +1,5 @@ -import type { z } from 'zod'; -import { fromZodError } from 'zod-validation-error'; +import type { z } from 'zod/v4'; +import { ValidationError } from './ValidationError.js'; let validationEnabled = true; @@ -35,21 +35,23 @@ export function isValidationEnabled() { * @param value - The value to parse * @param validationOverride - Force validation to run/not run regardless of your global preference * @returns The result from parsing + * @throws {@link ValidationError} + * Throws if the value does not pass validation, if enabled. * @internal */ -export function validate( +export function validate( validator: Validator, value: unknown, validationOverride?: boolean, ): z.output { - if (validationOverride === false || !isValidationEnabled()) { - return value; + if (validationOverride === false || (validationOverride === undefined && !isValidationEnabled())) { + return value as z.output; } const result = validator.safeParse(value); if (!result.success) { - throw fromZodError(result.error); + throw new ValidationError(result.error); } return result.data; diff --git a/packages/discord.js/src/structures/ApplicationCommand.js b/packages/discord.js/src/structures/ApplicationCommand.js index 0403af955..ad9382be5 100644 --- a/packages/discord.js/src/structures/ApplicationCommand.js +++ b/packages/discord.js/src/structures/ApplicationCommand.js @@ -579,7 +579,7 @@ class ApplicationCommand extends Base { * {@link ApplicationCommandOptionType.Number} option * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from * @property {ApplicationCommandOption[]} [options] Additional options if this option is a subcommand (group) - * @property {ApplicationCommandOptionAllowedChannelTypes[]} [channelTypes] When the option type is channel, + * @property {ApplicationCommandOptionAllowedChannelType[]} [channelTypes] When the option type is channel, * the allowed types of channels that can be selected * @property {number} [minValue] The minimum value for an {@link ApplicationCommandOptionType.Integer} or * {@link ApplicationCommandOptionType.Number} option @@ -653,6 +653,6 @@ class ApplicationCommand extends Base { exports.ApplicationCommand = ApplicationCommand; /** - * @external ApplicationCommandOptionAllowedChannelTypes - * @see {@link https://discord.js.org/docs/packages/builders/stable/ApplicationCommandOptionAllowedChannelTypes:TypeAlias} + * @external ApplicationCommandOptionAllowedChannelType + * @see {@link https://discord.js.org/docs/packages/builders/stable/ApplicationCommandOptionAllowedChannelType:TypeAlias} */ diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 440ff6360..926cc2773 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import { ChildProcess } from 'node:child_process'; import { Stream } from 'node:stream'; import { MessagePort, Worker } from 'node:worker_threads'; -import { ApplicationCommandOptionAllowedChannelTypes, MessageActionRowComponentBuilder } from '@discordjs/builders'; +import { ApplicationCommandOptionAllowedChannelType, MessageActionRowComponentBuilder } from '@discordjs/builders'; import { Collection, ReadonlyCollection } from '@discordjs/collection'; import { BaseImageURLOptions, ImageURLOptions, RawFile, REST, RESTOptions, EmojiURLOptions } from '@discordjs/rest'; import { Awaitable, JSONEncodable } from '@discordjs/util'; @@ -4823,13 +4823,13 @@ export type ApplicationCommandData = | UserApplicationCommandData; export interface ApplicationCommandChannelOptionData extends BaseApplicationCommandOptionsData { - channelTypes?: readonly ApplicationCommandOptionAllowedChannelTypes[]; - channel_types?: readonly ApplicationCommandOptionAllowedChannelTypes[]; + channelTypes?: readonly ApplicationCommandOptionAllowedChannelType[]; + channel_types?: readonly ApplicationCommandOptionAllowedChannelType[]; type: CommandOptionChannelResolvableType; } export interface ApplicationCommandChannelOption extends BaseApplicationCommandOptionsData { - channelTypes?: readonly ApplicationCommandOptionAllowedChannelTypes[]; + channelTypes?: readonly ApplicationCommandOptionAllowedChannelType[]; type: ApplicationCommandOptionType.Channel; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e9851584..3dcb32215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -759,11 +759,8 @@ importers: specifier: ^2.8.1 version: 2.8.1 zod: - specifier: ^3.24.2 - version: 3.24.3 - zod-validation-error: - specifier: ^3.4.0 - version: 3.4.0(zod@3.24.3) + specifier: ^3.25.69 + version: 3.25.69 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -13998,24 +13995,18 @@ packages: zlib-sync@0.1.10: resolution: {integrity: sha512-t7/pYg5tLBznL1RuhmbAt8rNp5tbhr+TSrJFnMkRtrGIaPJZ6Dc0uR4u3OoQI2d6cGlVI62E3Gy6gwkxyIqr/w==} - zod-validation-error@3.4.0: - resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.18.0 - zod-validation-error@3.4.1: resolution: {integrity: sha512-1KP64yqDPQ3rupxNv7oXhf7KdhHHgaqbKuspVoiN93TT0xrBjql+Svjkdjq/Qh/7GSMmgQs3AfvBT0heE35thw==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^3.24.4 - zod@3.24.3: - resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} - zod@3.25.39: resolution: {integrity: sha512-yrva2T2x4R+FMFTPBVD/YPS7ct8njqjnV93zNx/MlwqLAxcnxwRGbXWyWF63/nErl3rdRd8KARObon7BiWzabQ==} + zod@3.25.69: + resolution: {integrity: sha512-cjUx+boz8dfYGssYKLGNTF5VUF6NETpcZCDTN3knhUUXQTAAvErzRhV3pgeCZm2YsL4HOwtEHPonlsshPu2I0A==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -29506,16 +29497,12 @@ snapshots: dependencies: nan: 2.22.0 - zod-validation-error@3.4.0(zod@3.24.3): - dependencies: - zod: 3.24.3 - zod-validation-error@3.4.1(zod@3.25.39): dependencies: zod: 3.25.39 - zod@3.24.3: {} - zod@3.25.39: {} + zod@3.25.69: {} + zwitch@2.0.4: {}