feat!: use zod v4 (#10922)

* feat: zod 4

* feat: zod v3, but v4

feat: validation error class

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* chore: bump

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
This commit is contained in:
Almeida
2025-07-03 01:02:45 +01:00
committed by GitHub
parent 4dbeed933b
commit a5bd4cfe73
20 changed files with 183 additions and 199 deletions

View File

@@ -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', () => {

View File

@@ -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:^",

View File

@@ -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>
>,
);

View File

@@ -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()

View File

@@ -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(),

View File

@@ -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(),
});

View File

@@ -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';

View File

@@ -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(),
});

View File

@@ -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<APIApplicationCommandChannelOption, 'channel_types'> {}
@@ -36,7 +36,7 @@ export class ApplicationCommandOptionChannelTypesMixin {
*
* @param channelTypes - The channel types
*/
public addChannelTypes(...channelTypes: RestOrArray<ApplicationCommandOptionAllowedChannelTypes>) {
public addChannelTypes(...channelTypes: RestOrArray<ApplicationCommandOptionAllowedChannelType>) {
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<ApplicationCommandOptionAllowedChannelTypes>) {
public setChannelTypes(...channelTypes: RestOrArray<ApplicationCommandOptionAllowedChannelType>) {
this.data.channel_types = normalizeArray(channelTypes);
return this;
}

View File

@@ -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

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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.' });

View File

@@ -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(),
});

View File

@@ -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;
}
}

View File

@@ -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<Validator extends z.ZodTypeAny>(
export function validate<Validator extends z.ZodType>(
validator: Validator,
value: unknown,
validationOverride?: boolean,
): z.output<Validator> {
if (validationOverride === false || !isValidationEnabled()) {
return value;
if (validationOverride === false || (validationOverride === undefined && !isValidationEnabled())) {
return value as z.output<Validator>;
}
const result = validator.safeParse(value);
if (!result.success) {
throw fromZodError(result.error);
throw new ValidationError(result.error);
}
return result.data;

View File

@@ -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}
*/

View File

@@ -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;
}

27
pnpm-lock.yaml generated
View File

@@ -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: {}