Skip to content

Commit a5bd4cf

Browse files
almeidxJiraliteQjuh
authored
feat!: use zod v4 (discordjs#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>
1 parent 4dbeed9 commit a5bd4cf

File tree

20 files changed

+183
-199
lines changed

20 files changed

+183
-199
lines changed

packages/builders/__tests__/util.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { describe, test, expect } from 'vitest';
2-
import { enableValidators, disableValidators, isValidationEnabled, normalizeArray } from '../src/index.js';
2+
import { z } from 'zod/v4';
3+
import {
4+
enableValidators,
5+
disableValidators,
6+
isValidationEnabled,
7+
normalizeArray,
8+
ValidationError,
9+
} from '../src/index.js';
10+
import { validate } from '../src/util/validation.js';
311

412
describe('validation', () => {
513
test('enables validation', () => {
@@ -11,6 +19,17 @@ describe('validation', () => {
1119
disableValidators();
1220
expect(isValidationEnabled()).toBeFalsy();
1321
});
22+
23+
test('validation error', () => {
24+
try {
25+
validate(z.never(), 1, true);
26+
throw new Error('validation should have failed');
27+
} catch (error) {
28+
expect(error).toBeInstanceOf(ValidationError);
29+
expect((error as ValidationError).message).toBe('✖ Invalid input: expected never, received number');
30+
expect((error as ValidationError).cause).toBeInstanceOf(z.ZodError);
31+
}
32+
});
1433
});
1534

1635
describe('normalizeArray', () => {

packages/builders/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@
6969
"discord-api-types": "^0.38.14",
7070
"ts-mixer": "^6.0.4",
7171
"tslib": "^2.8.1",
72-
"zod": "^3.24.2",
73-
"zod-validation-error": "^3.4.0"
72+
"zod": "^3.25.69"
7473
},
7574
"devDependencies": {
7675
"@discordjs/api-extractor": "workspace:^",
Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
import { Locale } from 'discord-api-types/v10';
2-
import { z } from 'zod';
2+
import { z } from 'zod/v4';
33

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

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

8-
export const localeMapPredicate = z
9-
.object(
10-
Object.fromEntries(Object.values(Locale).map((loc) => [loc, z.string().optional()])) as Record<
11-
Locale,
12-
z.ZodOptional<z.ZodString>
13-
>,
14-
)
15-
.strict();
16-
17-
export const refineURLPredicate = (allowedProtocols: string[]) => (value: string) => {
18-
// eslint-disable-next-line n/prefer-global/url
19-
const url = new URL(value);
20-
return allowedProtocols.includes(url.protocol);
21-
};
8+
export const localeMapPredicate = z.strictObject(
9+
Object.fromEntries(Object.values(Locale).map((loc) => [loc, z.string().optional()])) as Record<
10+
Locale,
11+
z.ZodOptional<z.ZodString>
12+
>,
13+
);

packages/builders/src/components/Assertions.ts

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
2-
import { z } from 'zod';
3-
import { customIdPredicate, refineURLPredicate } from '../Assertions.js';
2+
import { z } from 'zod/v4';
3+
import { customIdPredicate } from '../Assertions.js';
44

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

77
export const emojiPredicate = z
8-
.object({
8+
.strictObject({
99
id: z.string().optional(),
1010
name: z.string().min(2).max(32).optional(),
1111
animated: z.boolean().optional(),
1212
})
13-
.strict()
1413
.refine((data) => data.id !== undefined || data.name !== undefined, {
15-
message: "Either 'id' or 'name' must be provided",
14+
error: "Either 'id' or 'name' must be provided",
1615
});
1716

18-
const buttonPredicateBase = z.object({
17+
const buttonPredicateBase = z.strictObject({
1918
type: z.literal(ComponentType.Button),
2019
disabled: z.boolean().optional(),
2120
});
@@ -26,31 +25,22 @@ const buttonCustomIdPredicateBase = buttonPredicateBase.extend({
2625
label: labelPredicate,
2726
});
2827

29-
const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) }).strict();
30-
const buttonSecondaryPredicate = buttonCustomIdPredicateBase
31-
.extend({ style: z.literal(ButtonStyle.Secondary) })
32-
.strict();
33-
const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) }).strict();
34-
const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) }).strict();
28+
const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) });
29+
const buttonSecondaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Secondary) });
30+
const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) });
31+
const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) });
3532

36-
const buttonLinkPredicate = buttonPredicateBase
37-
.extend({
38-
style: z.literal(ButtonStyle.Link),
39-
url: z
40-
.string()
41-
.url()
42-
.refine(refineURLPredicate(['http:', 'https:', 'discord:'])),
43-
emoji: emojiPredicate.optional(),
44-
label: labelPredicate,
45-
})
46-
.strict();
33+
const buttonLinkPredicate = buttonPredicateBase.extend({
34+
style: z.literal(ButtonStyle.Link),
35+
url: z.url({ protocol: /^(?:https?|discord)$/ }),
36+
emoji: emojiPredicate.optional(),
37+
label: labelPredicate,
38+
});
4739

48-
const buttonPremiumPredicate = buttonPredicateBase
49-
.extend({
50-
style: z.literal(ButtonStyle.Premium),
51-
sku_id: z.string(),
52-
})
53-
.strict();
40+
const buttonPremiumPredicate = buttonPredicateBase.extend({
41+
style: z.literal(ButtonStyle.Premium),
42+
sku_id: z.string(),
43+
});
5444

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

7262
export const selectMenuChannelPredicate = selectMenuBasePredicate.extend({
7363
type: z.literal(ComponentType.ChannelSelect),
74-
channel_types: z.nativeEnum(ChannelType).array().optional(),
64+
channel_types: z.enum(ChannelType).array().optional(),
7565
default_values: z
7666
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Channel) })
7767
.array()
@@ -84,7 +74,7 @@ export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({
8474
default_values: z
8575
.object({
8676
id: z.string(),
87-
type: z.union([z.literal(SelectMenuDefaultValueType.Role), z.literal(SelectMenuDefaultValueType.User)]),
77+
type: z.literal([SelectMenuDefaultValueType.Role, SelectMenuDefaultValueType.User]),
8878
})
8979
.array()
9080
.max(25)
@@ -113,23 +103,25 @@ export const selectMenuStringPredicate = selectMenuBasePredicate
113103
type: z.literal(ComponentType.StringSelect),
114104
options: selectMenuStringOptionPredicate.array().min(1).max(25),
115105
})
116-
.superRefine((menu, ctx) => {
106+
.check((ctx) => {
117107
const addIssue = (name: string, minimum: number) =>
118-
ctx.addIssue({
108+
ctx.issues.push({
119109
code: 'too_small',
120110
message: `The number of options must be greater than or equal to ${name}`,
121111
inclusive: true,
122112
minimum,
123113
type: 'number',
124114
path: ['options'],
115+
origin: 'number',
116+
input: minimum,
125117
});
126118

127-
if (menu.max_values !== undefined && menu.options.length < menu.max_values) {
128-
addIssue('max_values', menu.max_values);
119+
if (ctx.value.max_values !== undefined && ctx.value.options.length < ctx.value.max_values) {
120+
addIssue('max_values', ctx.value.max_values);
129121
}
130122

131-
if (menu.min_values !== undefined && menu.options.length < menu.min_values) {
132-
addIssue('min_values', menu.min_values);
123+
if (ctx.value.min_values !== undefined && ctx.value.options.length < ctx.value.min_values) {
124+
addIssue('min_values', ctx.value.min_values);
133125
}
134126
});
135127

@@ -152,14 +144,13 @@ export const actionRowPredicate = z.object({
152144
.max(5),
153145
z
154146
.object({
155-
type: z.union([
156-
z.literal(ComponentType.ChannelSelect),
157-
z.literal(ComponentType.MentionableSelect),
158-
z.literal(ComponentType.RoleSelect),
159-
z.literal(ComponentType.StringSelect),
160-
z.literal(ComponentType.UserSelect),
161-
// And this!
162-
z.literal(ComponentType.TextInput),
147+
type: z.literal([
148+
ComponentType.ChannelSelect,
149+
ComponentType.MentionableSelect,
150+
ComponentType.StringSelect,
151+
ComponentType.RoleSelect,
152+
ComponentType.TextInput,
153+
ComponentType.UserSelect,
163154
]),
164155
})
165156
.array()

packages/builders/src/components/textInput/Assertions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
2-
import { z } from 'zod';
2+
import { z } from 'zod/v4';
33
import { customIdPredicate } from '../../Assertions.js';
44

55
export const textInputPredicate = z.object({
66
type: z.literal(ComponentType.TextInput),
77
custom_id: customIdPredicate,
88
label: z.string().min(1).max(45),
9-
style: z.nativeEnum(TextInputStyle),
9+
style: z.enum(TextInputStyle),
1010
min_length: z.number().min(0).max(4_000).optional(),
1111
max_length: z.number().min(1).max(4_000).optional(),
1212
placeholder: z.string().max(100).optional(),

packages/builders/src/components/v2/Assertions.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10';
2-
import { z } from 'zod';
3-
import { refineURLPredicate } from '../../Assertions.js';
2+
import { z } from 'zod/v4';
43
import { actionRowPredicate } from '../Assertions.js';
54

65
const unfurledMediaItemPredicate = z.object({
7-
url: z
8-
.string()
9-
.url()
10-
.refine(refineURLPredicate(['http:', 'https:', 'attachment:']), {
11-
message: 'Invalid protocol for media URL. Must be http:, https:, or attachment:',
12-
}),
6+
url: z.url({ protocol: /^(?:https?|attachment)$/ }),
137
});
148

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

2115
const unfurledMediaItemAttachmentOnlyPredicate = z.object({
22-
url: z
23-
.string()
24-
.url()
25-
.refine(refineURLPredicate(['attachment:']), {
26-
message: 'Invalid protocol for file URL. Must be attachment:',
27-
}),
16+
url: z.url({ protocol: /^attachment$/ }),
2817
});
2918

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

3524
export const separatorPredicate = z.object({
3625
divider: z.boolean().optional(),
37-
spacing: z.nativeEnum(SeparatorSpacingSize).optional(),
26+
spacing: z.enum(SeparatorSpacingSize).optional(),
3827
});
3928

4029
export const textDisplayPredicate = z.object({
@@ -73,5 +62,5 @@ export const containerPredicate = z.object({
7362
)
7463
.min(1),
7564
spoiler: z.boolean().optional(),
76-
accent_color: z.number().int().min(0).max(0xffffff).nullish(),
65+
accent_color: z.int().min(0).max(0xffffff).nullish(),
7766
});

packages/builders/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export * from './util/componentUtil.js';
8787
export * from './util/normalizeArray.js';
8888
export * from './util/resolveBuilder.js';
8989
export { disableValidators, enableValidators, isValidationEnabled } from './util/validation.js';
90+
export * from './util/ValidationError.js';
9091

9192
export * from './Assertions.js';
9293

0 commit comments

Comments
 (0)