red64-cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +5 -0
- package/dist/cli/parseArgs.js.map +1 -1
- package/dist/components/init/CompleteStep.d.ts.map +1 -1
- package/dist/components/init/CompleteStep.js +2 -2
- package/dist/components/init/CompleteStep.js.map +1 -1
- package/dist/components/init/TestCheckStep.d.ts +16 -0
- package/dist/components/init/TestCheckStep.d.ts.map +1 -0
- package/dist/components/init/TestCheckStep.js +120 -0
- package/dist/components/init/TestCheckStep.js.map +1 -0
- package/dist/components/init/index.d.ts +1 -0
- package/dist/components/init/index.d.ts.map +1 -1
- package/dist/components/init/index.js +1 -0
- package/dist/components/init/index.js.map +1 -1
- package/dist/components/init/types.d.ts +9 -0
- package/dist/components/init/types.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.js +69 -6
- package/dist/components/screens/InitScreen.js.map +1 -1
- package/dist/components/screens/ListScreen.d.ts.map +1 -1
- package/dist/components/screens/ListScreen.js +28 -3
- package/dist/components/screens/ListScreen.js.map +1 -1
- package/dist/components/screens/StartScreen.d.ts.map +1 -1
- package/dist/components/screens/StartScreen.js +212 -13
- package/dist/components/screens/StartScreen.js.map +1 -1
- package/dist/components/ui/ArtifactsSidebar.d.ts +19 -0
- package/dist/components/ui/ArtifactsSidebar.d.ts.map +1 -0
- package/dist/components/ui/ArtifactsSidebar.js +51 -0
- package/dist/components/ui/ArtifactsSidebar.js.map +1 -0
- package/dist/components/ui/FeatureSidebar.d.ts.map +1 -1
- package/dist/components/ui/FeatureSidebar.js +1 -1
- package/dist/components/ui/FeatureSidebar.js.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/services/ClaudeErrorDetector.js +3 -3
- package/dist/services/ClaudeErrorDetector.js.map +1 -1
- package/dist/services/ConfigService.d.ts +1 -0
- package/dist/services/ConfigService.d.ts.map +1 -1
- package/dist/services/ConfigService.js.map +1 -1
- package/dist/services/ProjectDetector.d.ts +28 -0
- package/dist/services/ProjectDetector.d.ts.map +1 -0
- package/dist/services/ProjectDetector.js +236 -0
- package/dist/services/ProjectDetector.js.map +1 -0
- package/dist/services/TestRunner.d.ts +46 -0
- package/dist/services/TestRunner.d.ts.map +1 -0
- package/dist/services/TestRunner.js +85 -0
- package/dist/services/TestRunner.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/framework/.red64/settings/templates/specs/gap-analysis.md +163 -0
- package/framework/agents/claude/.claude/agents/red64/spec-impl.md +131 -2
- package/framework/agents/claude/.claude/agents/red64/validate-gap.md +13 -7
- package/framework/agents/claude/.claude/commands/red64/spec-impl.md +24 -0
- package/framework/agents/claude/.claude/commands/red64/validate-gap.md +4 -0
- package/framework/agents/codex/.codex/agents/red64/spec-impl.md +131 -2
- package/framework/agents/codex/.codex/agents/red64/validate-gap.md +13 -7
- package/framework/agents/codex/.codex/commands/red64/spec-impl.md +24 -0
- package/framework/agents/codex/.codex/commands/red64/validate-gap.md +4 -0
- package/framework/stacks/generic/feedback.md +80 -0
- package/framework/stacks/nextjs/accessibility.md +437 -0
- package/framework/stacks/nextjs/api.md +431 -0
- package/framework/stacks/nextjs/coding-style.md +282 -0
- package/framework/stacks/nextjs/commenting.md +226 -0
- package/framework/stacks/nextjs/components.md +411 -0
- package/framework/stacks/nextjs/conventions.md +333 -0
- package/framework/stacks/nextjs/css.md +310 -0
- package/framework/stacks/nextjs/error-handling.md +442 -0
- package/framework/stacks/nextjs/feedback.md +124 -0
- package/framework/stacks/nextjs/migrations.md +332 -0
- package/framework/stacks/nextjs/models.md +362 -0
- package/framework/stacks/nextjs/queries.md +410 -0
- package/framework/stacks/nextjs/responsive.md +338 -0
- package/framework/stacks/nextjs/tech-stack.md +177 -0
- package/framework/stacks/nextjs/test-writing.md +475 -0
- package/framework/stacks/nextjs/validation.md +467 -0
- package/framework/stacks/python/api.md +468 -0
- package/framework/stacks/python/authentication.md +342 -0
- package/framework/stacks/python/code-quality.md +283 -0
- package/framework/stacks/python/code-refactoring.md +315 -0
- package/framework/stacks/python/coding-style.md +462 -0
- package/framework/stacks/python/conventions.md +399 -0
- package/framework/stacks/python/error-handling.md +512 -0
- package/framework/stacks/python/feedback.md +92 -0
- package/framework/stacks/python/implement-ai-llm.md +468 -0
- package/framework/stacks/python/migrations.md +388 -0
- package/framework/stacks/python/models.md +399 -0
- package/framework/stacks/python/python.md +232 -0
- package/framework/stacks/python/queries.md +451 -0
- package/framework/stacks/python/structure.md +245 -58
- package/framework/stacks/python/tech.md +92 -35
- package/framework/stacks/python/testing.md +380 -0
- package/framework/stacks/python/validation.md +471 -0
- package/framework/stacks/rails/authentication.md +176 -0
- package/framework/stacks/rails/code-quality.md +287 -0
- package/framework/stacks/rails/code-refactoring.md +299 -0
- package/framework/stacks/rails/feedback.md +130 -0
- package/framework/stacks/rails/implement-ai-llm-with-rubyllm.md +342 -0
- package/framework/stacks/rails/rails.md +301 -0
- package/framework/stacks/rails/rails8-best-practices.md +498 -0
- package/framework/stacks/rails/rails8-css.md +573 -0
- package/framework/stacks/rails/structure.md +140 -0
- package/framework/stacks/rails/tech.md +108 -0
- package/framework/stacks/react/code-quality.md +521 -0
- package/framework/stacks/react/components.md +625 -0
- package/framework/stacks/react/data-fetching.md +586 -0
- package/framework/stacks/react/feedback.md +110 -0
- package/framework/stacks/react/forms.md +694 -0
- package/framework/stacks/react/performance.md +640 -0
- package/framework/stacks/react/product.md +22 -9
- package/framework/stacks/react/state-management.md +472 -0
- package/framework/stacks/react/structure.md +351 -44
- package/framework/stacks/react/tech.md +219 -30
- package/framework/stacks/react/testing.md +690 -0
- package/package.json +1 -1
- package/framework/stacks/node/product.md +0 -27
- package/framework/stacks/node/structure.md +0 -82
- package/framework/stacks/node/tech.md +0 -63
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# Validation Patterns
|
|
2
|
+
|
|
3
|
+
Zod schema validation for Next.js with server actions, API routes, forms, and reusable schema design.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Single source of truth**: Define schemas once, use on both client and server
|
|
10
|
+
- **Server is the authority**: Client validation improves UX; server validation enforces rules
|
|
11
|
+
- **Fail early, fail clearly**: Validate at the boundary, return specific field errors
|
|
12
|
+
- **Type inference**: Derive TypeScript types from Zod schemas, never duplicate
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Zod Schema Basics
|
|
17
|
+
|
|
18
|
+
### Defining Schemas
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// lib/validations/user.ts
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
export const createUserSchema = z.object({
|
|
25
|
+
email: z.string().email("Invalid email address"),
|
|
26
|
+
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
|
|
27
|
+
password: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(8, "Password must be at least 8 characters")
|
|
30
|
+
.regex(/[A-Z]/, "Password must contain an uppercase letter")
|
|
31
|
+
.regex(/[0-9]/, "Password must contain a number"),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const updateUserSchema = z.object({
|
|
35
|
+
name: z.string().min(1).max(255).optional(),
|
|
36
|
+
bio: z.string().max(500, "Bio must be 500 characters or less").optional(),
|
|
37
|
+
avatarUrl: z.string().url("Invalid URL").optional().or(z.literal("")),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Derive TypeScript types from schemas
|
|
41
|
+
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
|
42
|
+
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Common Field Patterns
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// Email
|
|
49
|
+
z.string().email("Invalid email address")
|
|
50
|
+
|
|
51
|
+
// Password with rules
|
|
52
|
+
z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/)
|
|
53
|
+
|
|
54
|
+
// Optional string that can be empty
|
|
55
|
+
z.string().optional().or(z.literal(""))
|
|
56
|
+
|
|
57
|
+
// URL (optional)
|
|
58
|
+
z.string().url().optional()
|
|
59
|
+
|
|
60
|
+
// Enum
|
|
61
|
+
z.enum(["ADMIN", "MEMBER", "VIEWER"])
|
|
62
|
+
|
|
63
|
+
// Date string from form input
|
|
64
|
+
z.string().pipe(z.coerce.date())
|
|
65
|
+
|
|
66
|
+
// Positive integer
|
|
67
|
+
z.coerce.number().int().positive()
|
|
68
|
+
|
|
69
|
+
// Boolean from checkbox (form sends "on" or undefined)
|
|
70
|
+
z.string().optional().transform((val) => val === "on")
|
|
71
|
+
|
|
72
|
+
// ID (cuid format)
|
|
73
|
+
z.string().cuid()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Server Action Validation
|
|
79
|
+
|
|
80
|
+
### Basic Pattern
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// actions/users.ts
|
|
84
|
+
"use server";
|
|
85
|
+
|
|
86
|
+
import { z } from "zod";
|
|
87
|
+
import { auth } from "@/lib/auth";
|
|
88
|
+
import { prisma } from "@/lib/prisma";
|
|
89
|
+
import { revalidatePath } from "next/cache";
|
|
90
|
+
import { createUserSchema } from "@/lib/validations/user";
|
|
91
|
+
|
|
92
|
+
type ActionState = {
|
|
93
|
+
success?: boolean;
|
|
94
|
+
error?: string;
|
|
95
|
+
fieldErrors?: Record<string, string[]>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export async function createUserAction(
|
|
99
|
+
_prevState: ActionState,
|
|
100
|
+
formData: FormData
|
|
101
|
+
): Promise<ActionState> {
|
|
102
|
+
const session = await auth();
|
|
103
|
+
if (!session?.user) {
|
|
104
|
+
return { error: "You must be signed in" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parsed = createUserSchema.safeParse({
|
|
108
|
+
email: formData.get("email"),
|
|
109
|
+
name: formData.get("name"),
|
|
110
|
+
password: formData.get("password"),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (!parsed.success) {
|
|
114
|
+
return {
|
|
115
|
+
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await prisma.user.create({
|
|
121
|
+
data: {
|
|
122
|
+
email: parsed.data.email,
|
|
123
|
+
name: parsed.data.name,
|
|
124
|
+
hashedPassword: await hash(parsed.data.password, 12),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (isPrismaUniqueConstraintError(error)) {
|
|
129
|
+
return { fieldErrors: { email: ["Email is already taken"] } };
|
|
130
|
+
}
|
|
131
|
+
return { error: "Failed to create user" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
revalidatePath("/users");
|
|
135
|
+
return { success: true };
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## API Route Validation
|
|
142
|
+
|
|
143
|
+
### Request Body Validation
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// app/api/posts/route.ts
|
|
147
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
148
|
+
import { z } from "zod";
|
|
149
|
+
|
|
150
|
+
const createPostSchema = z.object({
|
|
151
|
+
title: z.string().min(1).max(500),
|
|
152
|
+
body: z.string().min(1),
|
|
153
|
+
status: z.enum(["DRAFT", "PUBLISHED"]).default("DRAFT"),
|
|
154
|
+
tags: z.array(z.string()).max(10).default([]),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export async function POST(request: NextRequest) {
|
|
158
|
+
const body = await request.json().catch(() => null);
|
|
159
|
+
|
|
160
|
+
if (!body) {
|
|
161
|
+
return NextResponse.json(
|
|
162
|
+
{ error: { code: "BAD_REQUEST", message: "Invalid JSON body" } },
|
|
163
|
+
{ status: 400 }
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const parsed = createPostSchema.safeParse(body);
|
|
168
|
+
|
|
169
|
+
if (!parsed.success) {
|
|
170
|
+
return NextResponse.json(
|
|
171
|
+
{
|
|
172
|
+
error: {
|
|
173
|
+
code: "VALIDATION_ERROR",
|
|
174
|
+
message: "Invalid input",
|
|
175
|
+
details: parsed.error.flatten(),
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{ status: 422 }
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// parsed.data is fully typed
|
|
183
|
+
const post = await prisma.post.create({ data: parsed.data });
|
|
184
|
+
return NextResponse.json(post, { status: 201 });
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Query Parameter Validation
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
const listPostsSchema = z.object({
|
|
192
|
+
page: z.coerce.number().int().positive().default(1),
|
|
193
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
194
|
+
status: z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]).optional(),
|
|
195
|
+
search: z.string().max(200).optional(),
|
|
196
|
+
sort: z.string().regex(/^-?(createdAt|title|updatedAt)$/).default("-createdAt"),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export async function GET(request: NextRequest) {
|
|
200
|
+
const { searchParams } = request.nextUrl;
|
|
201
|
+
|
|
202
|
+
const parsed = listPostsSchema.safeParse(
|
|
203
|
+
Object.fromEntries(searchParams.entries())
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
return NextResponse.json(
|
|
208
|
+
{ error: { code: "VALIDATION_ERROR", message: "Invalid parameters" } },
|
|
209
|
+
{ status: 422 }
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const { page, limit, status, search, sort } = parsed.data;
|
|
214
|
+
// ... query with validated params
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Form Validation with React Hook Form
|
|
221
|
+
|
|
222
|
+
### Client-Side with Zod Resolver
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// components/forms/create-user-form.tsx
|
|
226
|
+
"use client";
|
|
227
|
+
|
|
228
|
+
import { useForm } from "react-hook-form";
|
|
229
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
230
|
+
import { createUserSchema, type CreateUserInput } from "@/lib/validations/user";
|
|
231
|
+
import { createUserAction } from "@/actions/users";
|
|
232
|
+
|
|
233
|
+
export function CreateUserForm() {
|
|
234
|
+
const {
|
|
235
|
+
register,
|
|
236
|
+
handleSubmit,
|
|
237
|
+
formState: { errors, isSubmitting },
|
|
238
|
+
setError,
|
|
239
|
+
} = useForm<CreateUserInput>({
|
|
240
|
+
resolver: zodResolver(createUserSchema),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
async function onSubmit(data: CreateUserInput) {
|
|
244
|
+
const formData = new FormData();
|
|
245
|
+
Object.entries(data).forEach(([key, value]) => formData.append(key, value));
|
|
246
|
+
|
|
247
|
+
const result = await createUserAction({}, formData);
|
|
248
|
+
|
|
249
|
+
if (result.fieldErrors) {
|
|
250
|
+
Object.entries(result.fieldErrors).forEach(([field, messages]) => {
|
|
251
|
+
setError(field as keyof CreateUserInput, {
|
|
252
|
+
message: messages[0],
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (result.error) {
|
|
259
|
+
setError("root", { message: result.error });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
|
265
|
+
<div>
|
|
266
|
+
<label htmlFor="email">Email</label>
|
|
267
|
+
<input id="email" type="email" {...register("email")} aria-invalid={!!errors.email} />
|
|
268
|
+
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div>
|
|
272
|
+
<label htmlFor="name">Name</label>
|
|
273
|
+
<input id="name" type="text" {...register("name")} aria-invalid={!!errors.name} />
|
|
274
|
+
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div>
|
|
278
|
+
<label htmlFor="password">Password</label>
|
|
279
|
+
<input id="password" type="password" {...register("password")} aria-invalid={!!errors.password} />
|
|
280
|
+
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{errors.root && <p className="text-sm text-destructive">{errors.root.message}</p>}
|
|
284
|
+
|
|
285
|
+
<button type="submit" disabled={isSubmitting}>
|
|
286
|
+
{isSubmitting ? "Creating..." : "Create User"}
|
|
287
|
+
</button>
|
|
288
|
+
</form>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Reusable Schema Patterns
|
|
296
|
+
|
|
297
|
+
### Shared Field Schemas
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// lib/validations/shared.ts
|
|
301
|
+
import { z } from "zod";
|
|
302
|
+
|
|
303
|
+
export const emailSchema = z.string().email("Invalid email address").toLowerCase().trim();
|
|
304
|
+
export const passwordSchema = z.string().min(8, "At least 8 characters").regex(/[A-Z]/, "Needs uppercase").regex(/[0-9]/, "Needs a number");
|
|
305
|
+
export const nameSchema = z.string().min(1, "Required").max(255).trim();
|
|
306
|
+
export const idSchema = z.string().cuid("Invalid ID");
|
|
307
|
+
export const paginationSchema = z.object({
|
|
308
|
+
page: z.coerce.number().int().positive().default(1),
|
|
309
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Composing Schemas
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
// lib/validations/user.ts
|
|
317
|
+
import { emailSchema, passwordSchema, nameSchema } from "./shared";
|
|
318
|
+
|
|
319
|
+
export const createUserSchema = z.object({
|
|
320
|
+
email: emailSchema,
|
|
321
|
+
name: nameSchema,
|
|
322
|
+
password: passwordSchema,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
export const loginSchema = z.object({
|
|
326
|
+
email: emailSchema,
|
|
327
|
+
password: z.string().min(1, "Password is required"),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Partial schema for updates
|
|
331
|
+
export const updateUserSchema = createUserSchema
|
|
332
|
+
.omit({ password: true })
|
|
333
|
+
.partial()
|
|
334
|
+
.extend({
|
|
335
|
+
bio: z.string().max(500).optional(),
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Advanced Patterns
|
|
342
|
+
|
|
343
|
+
### Discriminated Unions
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
const notificationSchema = z.discriminatedUnion("type", [
|
|
347
|
+
z.object({
|
|
348
|
+
type: z.literal("email"),
|
|
349
|
+
email: z.string().email(),
|
|
350
|
+
subject: z.string().min(1),
|
|
351
|
+
}),
|
|
352
|
+
z.object({
|
|
353
|
+
type: z.literal("sms"),
|
|
354
|
+
phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/),
|
|
355
|
+
message: z.string().max(160),
|
|
356
|
+
}),
|
|
357
|
+
z.object({
|
|
358
|
+
type: z.literal("push"),
|
|
359
|
+
token: z.string(),
|
|
360
|
+
title: z.string().min(1),
|
|
361
|
+
body: z.string().max(500),
|
|
362
|
+
}),
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
type Notification = z.infer<typeof notificationSchema>;
|
|
366
|
+
// Type is automatically narrowed based on "type" field
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Transform and Refine
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// Transform: change the output type
|
|
373
|
+
const slugSchema = z.string().transform((val) =>
|
|
374
|
+
val.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Refine: custom validation logic
|
|
378
|
+
const dateRangeSchema = z.object({
|
|
379
|
+
startDate: z.coerce.date(),
|
|
380
|
+
endDate: z.coerce.date(),
|
|
381
|
+
}).refine(
|
|
382
|
+
(data) => data.endDate > data.startDate,
|
|
383
|
+
{ message: "End date must be after start date", path: ["endDate"] }
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
// Superrefine: multiple custom errors
|
|
387
|
+
const registerSchema = z.object({
|
|
388
|
+
password: z.string().min(8),
|
|
389
|
+
confirmPassword: z.string(),
|
|
390
|
+
}).superRefine((data, ctx) => {
|
|
391
|
+
if (data.password !== data.confirmPassword) {
|
|
392
|
+
ctx.addIssue({
|
|
393
|
+
code: z.ZodIssueCode.custom,
|
|
394
|
+
message: "Passwords do not match",
|
|
395
|
+
path: ["confirmPassword"],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Preprocessing Form Data
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
// FormData sends everything as strings
|
|
405
|
+
// Use z.coerce or z.preprocess to handle this
|
|
406
|
+
|
|
407
|
+
const productSchema = z.object({
|
|
408
|
+
name: z.string().min(1),
|
|
409
|
+
price: z.coerce.number().positive("Price must be positive"),
|
|
410
|
+
quantity: z.coerce.number().int().nonnegative(),
|
|
411
|
+
isActive: z.preprocess((val) => val === "on" || val === "true", z.boolean()),
|
|
412
|
+
categories: z.preprocess(
|
|
413
|
+
(val) => (typeof val === "string" ? val.split(",").map((s) => s.trim()) : val),
|
|
414
|
+
z.array(z.string())
|
|
415
|
+
),
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## Error Message Customization
|
|
422
|
+
|
|
423
|
+
### Per-Field Messages
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
const schema = z.object({
|
|
427
|
+
email: z.string({
|
|
428
|
+
required_error: "Email is required",
|
|
429
|
+
invalid_type_error: "Email must be a string",
|
|
430
|
+
}).email("Please enter a valid email address"),
|
|
431
|
+
});
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Error Mapping
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// lib/validations/format-errors.ts
|
|
438
|
+
import type { ZodError } from "zod";
|
|
439
|
+
|
|
440
|
+
export function formatZodErrors(error: ZodError): Record<string, string> {
|
|
441
|
+
const formatted: Record<string, string> = {};
|
|
442
|
+
for (const issue of error.issues) {
|
|
443
|
+
const path = issue.path.join(".");
|
|
444
|
+
if (!formatted[path]) {
|
|
445
|
+
formatted[path] = issue.message;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return formatted;
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## Anti-Patterns
|
|
455
|
+
|
|
456
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
457
|
+
|---|---|---|
|
|
458
|
+
| Client-only validation | Server trusts unvalidated data | Always validate on server; client is UX only |
|
|
459
|
+
| Duplicating types and schemas | Types drift from validation | Derive types with `z.infer<typeof schema>` |
|
|
460
|
+
| Generic error messages | Users cannot fix their input | Specific, field-level error messages |
|
|
461
|
+
| Validating in the route handler | Duplicated across handlers | Shared schemas in `lib/validations/` |
|
|
462
|
+
| `z.any()` or `z.unknown()` passthrough | Defeats the purpose of validation | Define the exact shape you expect |
|
|
463
|
+
| No `safeParse` | Throws on invalid input | Use `safeParse` and handle errors gracefully |
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
_Validate at the boundary, fail with specificity, and let Zod's type inference eliminate the gap between runtime checks and compile-time types._
|