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