Kurs Next.js · Moduł 6: Formularze i Server Actions

Walidacja formularzy z Zod

6 min czytania
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:

  1. TypeScript-first - schematy Zod automatycznie generują typy, eliminując duplikację
  2. Zero zależności - lekka biblioteka (~10 KB gzipped)
  3. Standard Next.js - oficjalnie rekomendowany w dokumentacji Next.js
  4. Działa na serwerze i kliencie - idealne dla Server Actions

Instalacja i import

1npm install zod
1import { 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ów
  • parse() i safeParse() - walidacja danych
  • .min(), .email(), .regex() - reguły z komunikatami błędów
  • .refine() - niestandardowa walidacja
  • zodResolver - integracja z React Hook Form
  • z.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. 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

Przydatne artykuły