Kurs Next.js · Moduł 6: Formularze i Server Actions
Walidacja formularzy z Zod
W tej lekcji11
W Metropolis Quantum każdy system wymaga precyzyjnej walidacji danych - od terminali dostępowych po panele kontroli implantów. Zod to biblioteka walidacyjna zaprojektowana z myślą o TypeScript, która stała się standardem w ekosystemie Next.js. W przeciwieństwie do innych rozwiązań, Zod automatycznie generuje typy TypeScript na podstawie schematów walidacji, eliminując duplikację kodu.
Dlaczego Zod?
W roku 2150, systemy Metropolis Quantum wymagają niezawodnej walidacji danych. Zod wyróżnia się na tle innych bibliotek walidacyjnych (Yup, Joi) z kilku powodów:
- TypeScript-first - schematy Zod automatycznie generują typy, eliminując duplikację
- Zero zależności - lekka biblioteka (~10 KB gzipped)
- Standard Next.js - oficjalnie rekomendowany w dokumentacji Next.js
- Działa na serwerze i kliencie - idealne dla Server Actions
Instalacja i import
1npm install zod1import { z } from 'zod';Podstawowe schematy Zod
Zod oferuje zestaw metod do definiowania typów danych - jak cyfrowe DNA opisujące strukturę informacji w systemie:
1import { z } from 'zod';
2
3// Typy prymitywne
4const nameSchema = z.string();
5const ageSchema = z.number();
6const isActiveSchema = z.boolean();
7
8// Schema obiektu - łączymy typy w strukturę
9const userSchema = z.object({
10 name: z.string(),
11 email: z.string().email(),
12 age: z.number().int().positive(),
13 isActive: z.boolean(),
14});Każdy z.string(), z.number(), z.boolean() to podstawowy blok budulcowy schematu. Metoda z.object() łączy je w kompleksową strukturę - jak schemat terminala w Metropolis Quantum.
Metody walidacji: parse i safeParse
Zod oferuje dwa sposoby walidacji danych:
1import { z } from 'zod';
2
3const schema = z.object({
4 name: z.string().min(2),
5 email: z.string().email(),
6});
7
8// 1. parse() - rzuca wyjątek przy błędnych danych
9try {
10 const data = schema.parse({ name: "Neo", email: "neo@quantum.io" });
11 console.log("Dane poprawne:", data);
12} catch (error) {
13 console.error("Błąd walidacji:", error);
14}
15
16// 2. safeParse() - NIE rzuca wyjątku, zwraca obiekt z wynikiem
17const result = schema.safeParse({ name: "", email: "zly-email" });
18
19if (result.success) {
20 console.log("Dane:", result.data);
21} else {
22 console.log("Błędy:", result.error.errors);
23 // [{ path: ["name"], message: "String must contain at least 2 character(s)" }, ...]
24}Zasada: Używaj safeParse() w formularzach i Server Actions (bezpieczniejsze), a parse() gdy chcesz szybko przerwać wykonanie przy błędnych danych.
Komunikaty błędów i reguły walidacji
Zod pozwala na precyzyjne definiowanie reguł walidacji z własnymi komunikatami - jak konfiguracja systemu bezpieczeństwa w terminalu:
1import { z } from 'zod';
2
3const registrationSchema = z.object({
4 username: z.string()
5 .min(3, { message: "Nazwa użytkownika musi mieć co najmniej 3 znaki" })
6 .max(20, { message: "Nazwa użytkownika może mieć maksymalnie 20 znaków" }),
7 email: z.string()
8 .email({ message: "Nieprawidłowy format adresu email" }),
9 age: z.number()
10 .int({ message: "Wiek musi być liczbą całkowitą" })
11 .min(13, { message: "Musisz mieć co najmniej 13 lat" }),
12 password: z.string()
13 .min(8, { message: "Hasło musi mieć co najmniej 8 znaków" })
14 .regex(/[A-Z]/, { message: "Hasło musi zawierać wielką literę" })
15 .regex(/[0-9]/, { message: "Hasło musi zawierać cyfrę" }),
16 acceptTerms: z.boolean()
17 .refine(val => val === true, {
18 message: "Musisz zaakceptować warunki korzystania z systemu"
19 }),
20});Metoda .refine() pozwala na definiowanie własnych, niestandardowych reguł walidacji - przydatne, gdy wbudowane metody nie wystarczają.
Inferowanie typów: z.infer
Jedną z najpotężniejszych cech Zod jest automatyczne generowanie typów TypeScript ze schematów. Nie musisz utrzymywać oddzielnych interfejsów:
1import { z } from 'zod';
2
3const implantSchema = z.object({
4 id: z.string().uuid(),
5 name: z.string().min(1),
6 powerLevel: z.number().min(0).max(100),
7 isActive: z.boolean(),
8 tags: z.array(z.string()),
9});
10
11// Typ generowany automatycznie ze schematu
12type Implant = z.infer<typeof implantSchema>;
13// Odpowiednik ręcznego: { id: string; name: string; powerLevel: number; isActive: boolean; tags: string[] }
14
15// Teraz typ i walidacja są ZSYNCHRONIZOWANE
16function processImplant(data: Implant) {
17 console.log(data.name); // TypeScript zna typ!
18}Dzięki z.infer masz jedno źródło prawdy - schemat definiuje zarówno walidację, jak i typ TypeScript.
Integracja z React Hook Form
Zod doskonale współpracuje z React Hook Form dzięki adapterowi zodResolver. To najczęściej spotykany wzorzec w aplikacjach Next.js:
1import { useForm } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// 1. Definiujemy schemat
6const loginSchema = z.object({
7 email: z.string()
8 .email({ message: "Nieprawidłowy adres email" }),
9 password: z.string()
10 .min(8, { message: "Hasło musi mieć co najmniej 8 znaków" }),
11});
12
13// 2. Generujemy typ
14type LoginForm = z.infer<typeof loginSchema>;
15
16// 3. Używamy w komponencie
17export default function LoginPage() {
18 const {
19 register,
20 handleSubmit,
21 formState: { errors, isSubmitting }
22 } = useForm<LoginForm>({
23 resolver: zodResolver(loginSchema), // Zod jako walidator
24 defaultValues: { email: '', password: '' }
25 });
26
27 const onSubmit = async (data: LoginForm) => {
28 // data jest już zwalidowane i typowane!
29 console.log("Logowanie:", data);
30 };
31
32 return (
33 <form onSubmit={handleSubmit(onSubmit)}>
34 <input {...register('email')} placeholder="Email" />
35 {errors.email && <p>{errors.email.message}</p>}
36
37 <input {...register('password')} type="password" placeholder="Hasło" />
38 {errors.password && <p>{errors.password.message}</p>}
39
40 <button type="submit" disabled={isSubmitting}>
41 {isSubmitting ? 'Logowanie...' : 'Zaloguj się'}
42 </button>
43 </form>
44 );
45}Kluczowa linia to resolver: zodResolver(loginSchema) - łączy schemat Zod z systemem walidacji React Hook Form.
Reusable schematy i kompozycja
W dużych aplikacjach warto tworzyć reusable schematy, które można łączyć i rozszerzać:
1import { z } from 'zod';
2
3// Bazowy schemat adresu - używany w wielu miejscach
4const addressSchema = z.object({
5 street: z.string().min(1, "Ulica jest wymagana"),
6 city: z.string().min(1, "Miasto jest wymagane"),
7 postCode: z.string().regex(/^\d{2}-\d{3}$/, "Format: XX-XXX"),
8});
9
10// Schemat użytkownika - rozszerzamy o adres
11const userSchema = z.object({
12 name: z.string().min(2),
13 email: z.string().email(),
14 address: addressSchema, // Zagnieżdżanie
15 shippingAddress: addressSchema.optional(), // Opcjonalny
16});
17
18// .merge() - łączenie dwóch schematów
19const baseProfile = z.object({ name: z.string(), bio: z.string() });
20const socialProfile = z.object({ twitter: z.string(), github: z.string() });
21const fullProfile = baseProfile.merge(socialProfile);
22
23// .extend() - dodawanie pól do istniejącego schematu
24const adminUser = userSchema.extend({
25 role: z.literal('admin'),
26 permissions: z.array(z.string()),
27});
28
29type AdminUser = z.infer<typeof adminUser>;Zaawansowane techniki: refine i superRefine
Metoda .refine() pozwala na walidację, której nie da się wyrazić deklaratywnie - np. porównanie dwóch pól:
1import { z } from 'zod';
2
3const passwordSchema = z.object({
4 password: z.string().min(8),
5 confirmPassword: z.string(),
6}).refine(data => data.password === data.confirmPassword, {
7 message: "Hasła nie są identyczne",
8 path: ["confirmPassword"], // Błąd przypisany do pola confirmPassword
9});
10
11// superRefine - gdy potrzebujesz wielu błędów jednocześnie
12const advancedSchema = z.object({
13 accountType: z.enum(['personal', 'business']),
14 personalId: z.string().optional(),
15 companyId: z.string().optional(),
16}).superRefine((data, ctx) => {
17 if (data.accountType === 'personal' && !data.personalId) {
18 ctx.addIssue({
19 code: z.ZodIssueCode.custom,
20 message: "ID osobiste jest wymagane dla konta osobistego",
21 path: ['personalId'],
22 });
23 }
24 if (data.accountType === 'business' && !data.companyId) {
25 ctx.addIssue({
26 code: z.ZodIssueCode.custom,
27 message: "ID firmy jest wymagane dla konta biznesowego",
28 path: ['companyId'],
29 });
30 }
31});Alternatywy: Yup i Joi
Zod to standard w ekosystemie Next.js, ale istnieją alternatywy:
- Yup - popularna z Formik, deklaratywna składnia, ale wymaga ręcznych definicji typów TypeScript
- Joi - potężna, bogata w funkcje, ale duża (~40 KB) i głównie serwerowa
Zod jest rekomendowany dla projektów Next.js, ponieważ łączy walidację z typami TypeScript i działa zarówno na serwerze, jak i kliencie.
Podsumowanie
Zod to niezbędne narzędzie w arsenale programisty Next.js:
z.string(),z.number(),z.object()- definiowanie schematówparse()isafeParse()- walidacja danych.min(),.email(),.regex()- reguły z komunikatami błędów.refine()- niestandardowa walidacjazodResolver- integracja z React Hook Formz.infer<typeof schema>- automatyczne generowanie typów
Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Symulacja walidacji z Zod - schematy, parse, safeParse, komunikaty bledow
4
5// Uproszczony "Zod" do demonstracji (w prawdziwym projekcie obiekt z importujesz z pakietu zod)
6interface ZodIssue { path: string[]; message: string }
7interface ZodResult<T> { success: boolean; data?: T; error?: { issues: ZodIssue[] } }
8
9const z = {
10 string: () => {
11 const rules: Array<{ type: string; value?: any; msg: string }> = [];
12 const validator: any = {
13 min(n: number, opt?: { message?: string }) { rules.push({ type: 'min', value: n, msg: opt?.message || `Min ${n} znakow` }); return validator; },
14 max(n: number, opt?: { message?: string }) { rules.push({ type: 'max', value: n, msg: opt?.message || `Max ${n} znakow` }); return validator; },
15 email(opt?: { message?: string }) { rules.push({ type: 'email', msg: opt?.message || 'Nieprawidlowy email' }); return validator; },
16 regex(re: RegExp, opt?: { message?: string }) { rules.push({ type: 'regex', value: re, msg: opt?.message || 'Nieprawidlowy format' }); return validator; },
17 _validate(val: string): string | null {
18 if (typeof val !== 'string') return 'Wymagany string';
19 for (const rule of rules) {
20 if (rule.type === 'min' && val.length < rule.value) return rule.msg;
21 if (rule.type === 'max' && val.length > rule.value) return rule.msg;
22 if (rule.type === 'email' && !/\S+@\S+\.\S+/.test(val)) return rule.msg;
23 if (rule.type === 'regex' && !rule.value.test(val)) return rule.msg;
24 }
25 return null;
26 },
27 };
28 return validator;
29 },
30 coerce: {
31 number: () => {
32 const rules: Array<{ type: string; value?: any; msg: string }> = [];
33 const validator: any = {
34 min(n: number, opt?: { message?: string }) { rules.push({ type: 'min', value: n, msg: opt?.message || `Min ${n}` }); return validator; },
35 max(n: number, opt?: { message?: string }) { rules.push({ type: 'max', value: n, msg: opt?.message || `Max ${n}` }); return validator; },
36 int(opt?: { message?: string }) { rules.push({ type: 'int', msg: opt?.message || 'Wymagana liczba calkowita' }); return validator; },
37 _validate(val: string): string | null {
38 const num = Number(val);
39 if (val === '' || isNaN(num)) return 'Wymagana liczba';
40 for (const rule of rules) {
41 if (rule.type === 'min' && num < rule.value) return rule.msg;
42 if (rule.type === 'max' && num > rule.value) return rule.msg;
43 if (rule.type === 'int' && !Number.isInteger(num)) return rule.msg;
44 }
45 return null;
46 },
47 };
48 return validator;
49 },
50 },
51 object: (shape: Record<string, any>) => ({
52 safeParse(data: Record<string, string>): ZodResult<any> {
53 const issues: ZodIssue[] = [];
54 const parsed: any = {};
55 for (const [key, validator] of Object.entries(shape)) {
56 const err = validator._validate(data[key] || '');
57 if (err) issues.push({ path: [key], message: err });
58 else parsed[key] = data[key];
59 }
60 if (issues.length > 0) return { success: false, error: { issues } };
61 return { success: true, data: parsed };
62 },
63 parse(data: Record<string, string>) {
64 const result = this.safeParse(data);
65 if (!result.success) throw new Error('Zod validation failed: ' + result.error!.issues.map(i => i.message).join(', '));
66 return result.data;
67 },
68 }),
69};
70
71// --- SCHEMAT ZOD ---
72// W prawdziwym projekcie obiekt z pochodzi z pakietu zod
73const implantSchema = z.object({
74 name: z.string()
75 .min(2, { message: 'Nazwa musi miec co najmniej 2 znaki' })
76 .max(50, { message: 'Nazwa moze miec max 50 znakow' }),
77 email: z.string()
78 .email({ message: 'Nieprawidlowy format email' }),
79 powerLevel: z.coerce.number()
80 .min(1, { message: 'Poziom mocy musi byc wiekszy od 0' })
81 .max(100, { message: 'Poziom mocy nie moze przekraczac 100' }),
82});
83
84// type Implant = z.infer<typeof implantSchema>;
85
86export default function ZodValidationDemo() {
87 const [values, setValues] = useState({ name: '', email: '', powerLevel: '' });
88 const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
89 const [result, setResult] = useState<{ type: 'success' | 'error'; data: string } | null>(null);
90 const [mode, setMode] = useState<'safeParse' | 'parse'>('safeParse');
91
92 const handleSubmit = (e: React.FormEvent) => {
93 e.preventDefault();
94 setFieldErrors({});
95 setResult(null);
96
97 if (mode === 'safeParse') {
98 const res = implantSchema.safeParse(values);
99 if (res.success) {
100 setResult({ type: 'success', data: JSON.stringify(res.data, null, 2) });
101 } else {
102 const errs: Record<string, string> = {};
103 for (const issue of res.error!.issues) {
104 errs[issue.path[0]] = issue.message;
105 }
106 setFieldErrors(errs);
107 setResult({ type: 'error', data: JSON.stringify(res.error!.issues, null, 2) });
108 }
109 } else {
110 try {
111 const data = implantSchema.parse(values);
112 setResult({ type: 'success', data: JSON.stringify(data, null, 2) });
113 } catch (err: any) {
114 setResult({ type: 'error', data: err.message });
115 }
116 }
117 };
118
119 const inputStyle: React.CSSProperties = {
120 width: '100%', padding: '10px', background: '#0d1117',
121 border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
122 fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
123 };
124
125 const schemaCode = `const implantSchema = z.object({
126 name: z.string().min(2).max(50),
127 email: z.string().email(),
128 powerLevel: z.coerce.number().min(1).max(100),
129});
130
131type Implant = z.infer<typeof implantSchema>;`;
132
133 return (
134 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
135 <h1 style={{ color: '#64ffda' }}>Walidacja z Zod</h1>
136 <p style={{ color: '#888', fontSize: 13, marginBottom: 12 }}>
137 Przetestuj schemat Zod - wpisz dane i wybierz metode walidacji.
138 </p>
139
140 <pre style={{ background: '#0d1117', border: '1px solid #333', borderRadius: 8, padding: 12, fontSize: 11, color: '#ce93d8', marginBottom: 16 }}>
141{schemaCode}
142 </pre>
143
144 <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
145 {(['safeParse', 'parse'] as const).map(m => (
146 <button key={m} onClick={() => { setMode(m); setResult(null); setFieldErrors({}); }} style={{
147 background: mode === m ? '#1565c0' : '#1a2744',
148 color: '#fff', border: `1px solid ${mode === m ? '#64ffda' : '#333'}`,
149 borderRadius: 6, padding: '6px 14px', cursor: 'pointer', fontFamily: 'monospace', fontSize: 12
150 }}>
151 schema.{m}()
152 </button>
153 ))}
154 </div>
155
156 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
157 {(['name', 'email', 'powerLevel'] as const).map(field => (
158 <div key={field} style={{ marginBottom: 12 }}>
159 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
160 {field}
161 </label>
162 <input
163 value={values[field]}
164 onChange={e => setValues(prev => ({ ...prev, [field]: e.target.value }))}
165 placeholder={field === 'powerLevel' ? '1-100' : field === 'email' ? 'neo@quantum.io' : 'Neural Link'}
166 style={{ ...inputStyle, borderColor: fieldErrors[field] ? '#f44336' : '#333' }}
167 />
168 {fieldErrors[field] && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{fieldErrors[field]}</div>}
169 </div>
170 ))}
171 <button type="submit" style={{
172 background: '#e91e63', color: '#fff', border: 'none', borderRadius: 6,
173 padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
174 }}>
175 schema.{mode}(formData)
176 </button>
177 </form>
178
179 {result && (
180 <div style={{
181 marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
182 background: result.type === 'success' ? '#1b5e20' : '#3e1010',
183 border: `1px solid ${result.type === 'success' ? '#4caf50' : '#f44336'}`
184 }}>
185 <div style={{ color: result.type === 'success' ? '#4caf50' : '#f44336', marginBottom: 4 }}>
186 {result.type === 'success' ? 'Walidacja udana! Typowany wynik:' : mode === 'parse' ? 'parse() rzucil wyjatek:' : 'safeParse() zwrocil bledy:'}
187 </div>
188 <pre style={{ color: '#ccc', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{result.data}</pre>
189 </div>
190 )}
191 </div>
192 );
193}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Czym różni się metoda safeParse() od parse() w Zod?
Zadania praktyczne w grze
- Edytor kodu
Stwórz schemat Zod z walidacją pól (name, email, powerLevel) i zintegruj go z React Hook Form przez zodResolver
- Układanie w pionie
Ułóż kroki integracji Zod z React Hook Form w prawidłowej kolejności
- Klikanie w kolejności
Ułóż składnię importu biblioteki Zod
- Edytor kodu
Utwórz formularz kontaktowy z polami: name, email, message używając kontrolowanych komponentów i useState