7.9 טפסים React Hook Form ו Zod פתרון
פתרון - טפסים - React Hook Form ו-Zod¶
פתרון תרגיל 1 - טופס הרשמה בסיסי¶
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from "react";
const registrationSchema = z
.object({
firstName: z.string().min(2, "שם פרטי חייב להכיל לפחות 2 תווים"),
lastName: z.string().min(2, "שם משפחה חייב להכיל לפחות 2 תווים"),
email: z.string().email("כתובת אימייל לא תקינה"),
password: z
.string()
.min(8, "סיסמה חייבת להכיל לפחות 8 תווים")
.regex(/[A-Z]/, "חייבת לכלול לפחות אות גדולה אחת")
.regex(/[0-9]/, "חייבת לכלול לפחות ספרה אחת")
.regex(/[^A-Za-z0-9]/, "חייבת לכלול לפחות תו מיוחד אחד"),
confirmPassword: z.string(),
birthDate: z.string().refine(
(date) => {
const age = (Date.now() - new Date(date).getTime()) / (365.25 * 24 * 60 * 60 * 1000);
return age >= 16;
},
"גיל מינימלי להרשמה הוא 16"
),
})
.refine((data) => data.password === data.confirmPassword, {
message: "הסיסמאות לא תואמות",
path: ["confirmPassword"],
});
type RegistrationData = z.infer<typeof registrationSchema>;
function RegistrationForm() {
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
birthDate: "",
},
});
const onSubmit = async (data: RegistrationData) => {
await new Promise((r) => setTimeout(r, 1500));
console.log("נרשם:", data);
setSuccess(true);
reset();
setTimeout(() => setSuccess(false), 3000);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>הרשמה</h2>
{success && (
<div style={{ padding: "12px", backgroundColor: "#d4edda", borderRadius: "4px" }}>
ההרשמה בוצעה בהצלחה!
</div>
)}
<div>
<label>שם פרטי</label>
<input {...register("firstName")} />
{errors.firstName && <p style={{ color: "red" }}>{errors.firstName.message}</p>}
</div>
<div>
<label>שם משפחה</label>
<input {...register("lastName")} />
{errors.lastName && <p style={{ color: "red" }}>{errors.lastName.message}</p>}
</div>
<div>
<label>אימייל</label>
<input type="email" {...register("email")} />
{errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
</div>
<div>
<label>סיסמה</label>
<input type="password" {...register("password")} />
{errors.password && <p style={{ color: "red" }}>{errors.password.message}</p>}
</div>
<div>
<label>אישור סיסמה</label>
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && (
<p style={{ color: "red" }}>{errors.confirmPassword.message}</p>
)}
</div>
<div>
<label>תאריך לידה</label>
<input type="date" {...register("birthDate")} />
{errors.birthDate && <p style={{ color: "red" }}>{errors.birthDate.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "נרשם..." : "הירשם"}
</button>
</form>
);
}
הסבר:
- הסכמה כוללת ולידציות ברמת שדה (min, regex) וברמת טופס (refine להשוואת סיסמאות)
- תאריך הלידה נבדק עם refine שמחשב גיל ומוודא מינימום 16
- אחרי שליחה מוצלחת, הטופס מתאפס עם reset והודעת הצלחה מוצגת
פתרון תרגיל 2 - טופס כתובת עם אובייקטים מקוננים¶
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const addressSchema = z.object({
street: z.string().min(1, "רחוב נדרש"),
city: z.string().min(1, "עיר נדרשת"),
zip: z.string().regex(/^\d{7}$/, "מיקוד חייב להכיל 7 ספרות"),
floor: z.string().optional(),
});
const orderSchema = z.object({
personal: z.object({
firstName: z.string().min(1, "שם פרטי נדרש"),
lastName: z.string().min(1, "שם משפחה נדרש"),
phone: z.string().regex(/^05\d{8}$/, "מספר טלפון לא תקין (05XXXXXXXX)"),
}),
shippingAddress: addressSchema,
sameAsBilling: z.boolean(),
billingAddress: addressSchema.optional(),
});
type OrderData = z.infer<typeof orderSchema>;
function OrderForm() {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<OrderData>({
resolver: zodResolver(orderSchema),
defaultValues: {
personal: { firstName: "", lastName: "", phone: "" },
shippingAddress: { street: "", city: "", zip: "", floor: "" },
sameAsBilling: true,
billingAddress: { street: "", city: "", zip: "", floor: "" },
},
});
const sameAsBilling = watch("sameAsBilling");
const shippingAddress = watch("shippingAddress");
const onSubmit = (data: OrderData) => {
if (data.sameAsBilling) {
data.billingAddress = data.shippingAddress;
}
console.log("הזמנה:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>פרטים אישיים</h2>
<input {...register("personal.firstName")} placeholder="שם פרטי" />
{errors.personal?.firstName && (
<p style={{ color: "red" }}>{errors.personal.firstName.message}</p>
)}
<input {...register("personal.lastName")} placeholder="שם משפחה" />
{errors.personal?.lastName && (
<p style={{ color: "red" }}>{errors.personal.lastName.message}</p>
)}
<input {...register("personal.phone")} placeholder="טלפון (05XXXXXXXX)" />
{errors.personal?.phone && (
<p style={{ color: "red" }}>{errors.personal.phone.message}</p>
)}
<h2>כתובת משלוח</h2>
<input {...register("shippingAddress.street")} placeholder="רחוב" />
{errors.shippingAddress?.street && (
<p style={{ color: "red" }}>{errors.shippingAddress.street.message}</p>
)}
<input {...register("shippingAddress.city")} placeholder="עיר" />
<input {...register("shippingAddress.zip")} placeholder="מיקוד" />
{errors.shippingAddress?.zip && (
<p style={{ color: "red" }}>{errors.shippingAddress.zip.message}</p>
)}
<input {...register("shippingAddress.floor")} placeholder="קומה" />
<div>
<label>
<input type="checkbox" {...register("sameAsBilling")} />
כתובת חיוב זהה לכתובת משלוח
</label>
</div>
{!sameAsBilling && (
<>
<h2>כתובת חיוב</h2>
<input {...register("billingAddress.street")} placeholder="רחוב" />
<input {...register("billingAddress.city")} placeholder="עיר" />
<input {...register("billingAddress.zip")} placeholder="מיקוד" />
<input {...register("billingAddress.floor")} placeholder="קומה" />
</>
)}
<button type="submit">שלח הזמנה</button>
</form>
);
}
הסבר:
- סכמה מקוננת עם addressSchema שמשמש גם למשלוח וגם לחיוב
- watch("sameAsBilling") מאפשר להציג/להסתיר את כתובת החיוב בזמן אמת
- בשליחה, אם sameAsBilling מסומן, מעתיקים את כתובת המשלוח לחיוב
פתרון תרגיל 3 - טופס עם מערך דינמי¶
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const recipeSchema = z.object({
name: z.string().min(2, "שם מתכון נדרש"),
description: z.string().min(10, "תיאור חייב להכיל לפחות 10 תווים"),
prepTime: z.number().min(1, "זמן הכנה חייב להיות לפחות דקה"),
difficulty: z.enum(["easy", "medium", "hard"]),
category: z.string().min(1, "קטגוריה נדרשת"),
ingredients: z
.array(
z.object({
name: z.string().min(1, "שם מרכיב נדרש"),
quantity: z.string().min(1, "כמות נדרשת"),
unit: z.string().min(1, "יחידה נדרשת"),
})
)
.min(1, "לפחות מרכיב אחד נדרש")
.max(20, "מקסימום 20 מרכיבים"),
steps: z
.array(z.object({ text: z.string().min(1, "שלב לא יכול להיות ריק") }))
.min(1, "לפחות שלב אחד נדרש"),
});
type RecipeData = z.infer<typeof recipeSchema>;
function RecipeForm() {
const {
register,
control,
handleSubmit,
watch,
formState: { errors },
} = useForm<RecipeData>({
resolver: zodResolver(recipeSchema),
defaultValues: {
name: "",
description: "",
prepTime: 30,
difficulty: "medium",
category: "",
ingredients: [{ name: "", quantity: "", unit: "גרם" }],
steps: [{ text: "" }],
},
});
const ingredients = useFieldArray({ control, name: "ingredients" });
const steps = useFieldArray({ control, name: "steps" });
const formValues = watch();
const onSubmit = (data: RecipeData) => {
console.log("מתכון:", data);
};
const difficultyLabels = { easy: "קל", medium: "בינוני", hard: "קשה" };
return (
<div style={{ display: "flex", gap: "24px" }}>
<form onSubmit={handleSubmit(onSubmit)} style={{ flex: 1 }}>
<h2>יצירת מתכון</h2>
<div>
<label>שם המתכון</label>
<input {...register("name")} />
{errors.name && <p style={{ color: "red" }}>{errors.name.message}</p>}
</div>
<div>
<label>תיאור</label>
<textarea {...register("description")} />
{errors.description && <p style={{ color: "red" }}>{errors.description.message}</p>}
</div>
<div>
<label>זמן הכנה (דקות)</label>
<input type="number" {...register("prepTime", { valueAsNumber: true })} />
</div>
<div>
<label>רמת קושי</label>
<select {...register("difficulty")}>
<option value="easy">קל</option>
<option value="medium">בינוני</option>
<option value="hard">קשה</option>
</select>
</div>
<div>
<label>קטגוריה</label>
<input {...register("category")} />
</div>
<h3>מרכיבים</h3>
{ingredients.fields.map((field, index) => (
<div key={field.id} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
<input {...register(`ingredients.${index}.name`)} placeholder="שם" />
<input {...register(`ingredients.${index}.quantity`)} placeholder="כמות" />
<select {...register(`ingredients.${index}.unit`)}>
<option value="גרם">גרם</option>
<option value="כוס">כוס</option>
<option value="כף">כף</option>
<option value="כפית">כפית</option>
<option value="יחידה">יחידה</option>
</select>
{ingredients.fields.length > 1 && (
<button type="button" onClick={() => ingredients.remove(index)}>X</button>
)}
</div>
))}
{errors.ingredients?.root && (
<p style={{ color: "red" }}>{errors.ingredients.root.message}</p>
)}
<button
type="button"
onClick={() => ingredients.append({ name: "", quantity: "", unit: "גרם" })}
disabled={ingredients.fields.length >= 20}
>
הוסף מרכיב
</button>
<h3>שלבי הכנה</h3>
{steps.fields.map((field, index) => (
<div key={field.id} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
<span>{index + 1}.</span>
<textarea {...register(`steps.${index}.text`)} placeholder={`שלב ${index + 1}`} />
{steps.fields.length > 1 && (
<button type="button" onClick={() => steps.remove(index)}>X</button>
)}
</div>
))}
<button type="button" onClick={() => steps.append({ text: "" })}>
הוסף שלב
</button>
<button type="submit" style={{ display: "block", marginTop: "16px" }}>
שמור מתכון
</button>
</form>
<div style={{ flex: 1, padding: "16px", backgroundColor: "#f9f9f9", borderRadius: "8px" }}>
<h2>תצוגה מקדימה</h2>
<h3>{formValues.name || "שם המתכון"}</h3>
<p>{formValues.description || "תיאור..."}</p>
<p>זמן הכנה: {formValues.prepTime} דקות | קושי: {difficultyLabels[formValues.difficulty]}</p>
<h4>מרכיבים:</h4>
<ul>
{formValues.ingredients?.map((ing, i) => (
<li key={i}>
{ing.quantity} {ing.unit} {ing.name}
</li>
))}
</ul>
<h4>הוראות הכנה:</h4>
<ol>
{formValues.steps?.map((step, i) => (
<li key={i}>{step.text || "..."}</li>
))}
</ol>
</div>
</div>
);
}
הסبר:
- שני useFieldArray נפרדים: אחד למרכיבים ואחד לשלבי הכנה
- watch() בלי פרמטרים מחזיר את כל ערכי הטופס - משמש לתצוגה מקדימה
- כפתור הוספת מרכיב מושבת כשמגיעים למקסימום
פתרון תרגיל 4 - טופס רב-שלבי¶
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState, useEffect } from "react";
const step1Schema = z.object({
name: z.string().min(2, "שם נדרש"),
email: z.string().email("אימייל לא תקין"),
phone: z.string().min(9, "טלפון לא תקין"),
});
const step2Schema = z.object({
categories: z.array(z.string()).min(1, "בחר לפחות קטגוריה אחת"),
frequency: z.enum(["daily", "weekly", "monthly"]),
language: z.string(),
});
const step3Schema = z.object({
cardNumber: z.string().regex(/^\d{16}$/, "מספר כרטיס חייב להכיל 16 ספרות"),
expiry: z.string().regex(/^\d{2}\/\d{2}$/, "פורמט MM/YY"),
cvv: z.string().regex(/^\d{3}$/, "CVV חייב להכיל 3 ספרות"),
});
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FullFormData = z.infer<typeof fullSchema>;
const schemas = [step1Schema, step2Schema, step3Schema];
const stepTitles = ["פרטים אישיים", "העדפות", "תשלום", "סיכום"];
function MultiStepForm() {
const [step, setStep] = useState(0);
const form = useForm<FullFormData>({
resolver: zodResolver(schemas[step] || fullSchema),
defaultValues: {
name: "",
email: "",
phone: "",
categories: [],
frequency: "weekly",
language: "he",
cardNumber: "",
expiry: "",
cvv: "",
},
mode: "onBlur",
});
const { handleSubmit, watch, trigger, getValues } = form;
const values = watch();
// שמירת טיוטה ב-localStorage
useEffect(() => {
const subscription = watch((data) => {
localStorage.setItem("formDraft", JSON.stringify(data));
});
return () => subscription.unsubscribe();
}, [watch]);
// טעינת טיוטה
useEffect(() => {
const draft = localStorage.getItem("formDraft");
if (draft) {
form.reset(JSON.parse(draft));
}
}, []);
const nextStep = async () => {
const valid = await trigger();
if (valid) setStep((s) => Math.min(s + 1, 3));
};
const prevStep = () => setStep((s) => Math.max(s - 1, 0));
const onSubmit = (data: FullFormData) => {
console.log("נתונים סופיים:", data);
localStorage.removeItem("formDraft");
alert("הטופס נשלח בהצלחה!");
};
return (
<div>
<h2>טופס הרשמה</h2>
<div style={{ display: "flex", gap: "4px", marginBottom: "24px" }}>
{stepTitles.map((title, i) => (
<div
key={i}
style={{
flex: 1,
padding: "8px",
textAlign: "center",
backgroundColor: i <= step ? "#3b82f6" : "#e5e7eb",
color: i <= step ? "white" : "#666",
borderRadius: "4px",
}}
>
{title}
</div>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{step === 0 && (
<div>
<h3>פרטים אישיים</h3>
<input {...form.register("name")} placeholder="שם" />
{form.formState.errors.name && (
<p style={{ color: "red" }}>{form.formState.errors.name.message}</p>
)}
<input {...form.register("email")} placeholder="אימייל" />
{form.formState.errors.email && (
<p style={{ color: "red" }}>{form.formState.errors.email.message}</p>
)}
<input {...form.register("phone")} placeholder="טלפון" />
{form.formState.errors.phone && (
<p style={{ color: "red" }}>{form.formState.errors.phone.message}</p>
)}
</div>
)}
{step === 1 && (
<div>
<h3>העדפות</h3>
<div>
{["טכנולוגיה", "עיצוב", "שיווק", "ניהול"].map((cat) => (
<label key={cat} style={{ display: "block" }}>
<input type="checkbox" value={cat} {...form.register("categories")} />
{cat}
</label>
))}
</div>
<select {...form.register("frequency")}>
<option value="daily">יומי</option>
<option value="weekly">שבועי</option>
<option value="monthly">חודשי</option>
</select>
</div>
)}
{step === 2 && (
<div>
<h3>פרטי תשלום</h3>
<input {...form.register("cardNumber")} placeholder="מספר כרטיס" />
{form.formState.errors.cardNumber && (
<p style={{ color: "red" }}>{form.formState.errors.cardNumber.message}</p>
)}
<input {...form.register("expiry")} placeholder="MM/YY" />
<input {...form.register("cvv")} placeholder="CVV" />
</div>
)}
{step === 3 && (
<div>
<h3>סיכום</h3>
<p>שם: {values.name}</p>
<p>אימייל: {values.email}</p>
<p>טלפון: {values.phone}</p>
<p>קטגוריות: {values.categories?.join(", ")}</p>
<p>תדירות: {values.frequency}</p>
<p>כרטיס: ****{values.cardNumber?.slice(-4)}</p>
</div>
)}
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
{step > 0 && (
<button type="button" onClick={prevStep}>
חזרה
</button>
)}
{step < 3 ? (
<button type="button" onClick={nextStep}>
הבא
</button>
) : (
<button type="submit">אשר ושלח</button>
)}
</div>
</form>
</div>
);
}
הסبר:
- כל שלב מולידט עם סכמה משלו - כך אפשר לעבור לשלב הבא רק אם השלב הנוכחי תקין
- trigger() מריץ ולידציה ידנית ומחזיר אם תקין
- watch() עם subscription שומר טיוטה ב-localStorage בכל שינוי
- בדף סיכום מוצגים כל הנתונים שהוזנו
פתרון תרגיל 5 - טופס עם ולידציה אסינכרונית¶
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState, useEffect, useRef } from "react";
const takenUsernames = ["admin", "user", "test", "demo"];
const takenEmails = ["admin@example.com", "test@example.com"];
async function checkUsername(username: string): Promise<boolean> {
await new Promise((r) => setTimeout(r, 800));
return !takenUsernames.includes(username.toLowerCase());
}
async function checkEmail(email: string): Promise<boolean> {
await new Promise((r) => setTimeout(r, 800));
return !takenEmails.includes(email.toLowerCase());
}
const schema = z.object({
username: z.string().min(3, "לפחות 3 תווים").max(20, "מקסימום 20 תווים"),
email: z.string().email("אימייל לא תקין"),
password: z.string().min(8, "לפחות 8 תווים"),
});
type FormData = z.infer<typeof schema>;
function AsyncValidationForm() {
const {
register,
handleSubmit,
watch,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onBlur",
});
const [checkingUsername, setCheckingUsername] = useState(false);
const [checkingEmail, setCheckingEmail] = useState(false);
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
const usernameTimer = useRef<number | null>(null);
const emailTimer = useRef<number | null>(null);
const username = watch("username");
const email = watch("email");
// Debounced username check
useEffect(() => {
if (!username || username.length < 3) {
setUsernameAvailable(null);
return;
}
setCheckingUsername(true);
if (usernameTimer.current) clearTimeout(usernameTimer.current);
usernameTimer.current = window.setTimeout(async () => {
const available = await checkUsername(username);
setUsernameAvailable(available);
setCheckingUsername(false);
if (!available) {
setError("username", { message: "שם המשתמש תפוס" });
} else {
clearErrors("username");
}
}, 500);
return () => {
if (usernameTimer.current) clearTimeout(usernameTimer.current);
};
}, [username, setError, clearErrors]);
// Debounced email check
useEffect(() => {
if (!email || !email.includes("@")) {
setEmailAvailable(null);
return;
}
setCheckingEmail(true);
if (emailTimer.current) clearTimeout(emailTimer.current);
emailTimer.current = window.setTimeout(async () => {
const available = await checkEmail(email);
setEmailAvailable(available);
setCheckingEmail(false);
if (!available) {
setError("email", { message: "האימייל כבר רשום" });
} else {
clearErrors("email");
}
}, 500);
return () => {
if (emailTimer.current) clearTimeout(emailTimer.current);
};
}, [email, setError, clearErrors]);
const onSubmit = (data: FormData) => {
console.log("נשלח:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>הרשמה עם ולידציה אסינכרונית</h2>
<div>
<label>שם משתמש</label>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input {...register("username")} />
{checkingUsername && <span>בודק...</span>}
{!checkingUsername && usernameAvailable === true && (
<span style={{ color: "green" }}>זמין</span>
)}
</div>
{errors.username && <p style={{ color: "red" }}>{errors.username.message}</p>}
</div>
<div>
<label>אימייל</label>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="email" {...register("email")} />
{checkingEmail && <span>בודק...</span>}
{!checkingEmail && emailAvailable === true && (
<span style={{ color: "green" }}>זמין</span>
)}
</div>
{errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
</div>
<div>
<label>סיסמה</label>
<input type="password" {...register("password")} />
{errors.password && <p style={{ color: "red" }}>{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting || checkingUsername || checkingEmail}>
הירשם
</button>
</form>
);
}
הסبר:
- debounce עם useRef ו-setTimeout - הבדיקה רצה רק 500ms אחרי שהמשתמש הפסיק להקליד
- setError ו-clearErrors מאפשרים להוסיף/להסיר שגיאות ידנית
- מוצג סטטוס בדיקה (spinner) וזמינות (ירוק) ליד כל שדה
תשובות לשאלות¶
-
ביצועים של React Hook Form: React Hook Form משתמש ב-uncontrolled inputs (refs) ולא ב-useState. זה אומר שהקלדה בשדה לא גורמת לרנדר מחדש של הטופס כולו. Formik, לעומת זאת, משתמש ב-controlled inputs עם useState, מה שגורם לרנדר בכל שינוי. בטפסים עם הרבה שדות, ההבדל משמעותי.
-
Zod מול ולידציה ישירה: Zod מספקת: טיפוסי TypeScript אוטומטיים (z.infer), ולידציות מורכבות בקלות (refine, transform), שימוש חוזר בסכמות (merge, extend), ולידציה גם בצד השרת עם אותה סכמה. ולידציה ישירה ב-register מוגבלת לשדה בודד ולא תומכת בולידציה חוצת-שדות.
-
onChange מול onBlur: mode: "onChange" מריץ ולידציה בכל הקלדה - UX תגובתי אבל יותר רנדרים. mode: "onBlur" מריץ ולידציה רק כשעוזבים שדה - פחות רנדרים אבל השגיאות מופיעות רק אחרי שהמשתמש עזב את השדה. mode: "onTouched" הוא פשרה טובה - ולידציה ראשונה ב-onBlur, ואז onChange.
-
ביצועים של useFieldArray: useFieldArray משתמש ב-key ייחודי (field.id) לכל פריט ומעדכן רק את הפריטים שהשתנו. הוא לא מרנדר את כל המערך בכל שינוי. בנוסף, register עם indexes (
items.${index}.name) מאפשר עדכון ממוקד ללא רנדר של פריטים אחרים. -
z.infer מול interface: z.infer מבטיח שהטיפוס תמיד מסונכרן עם הסכמה - אם מוסיפים שדה לסכמה, הטיפוס מתעדכן אוטומטית. עם interface ידני, אפשר לשכוח לעדכן את הטיפוס כשמשנים את הסכמה, מה שגורם לבאגים שקשה למצוא. Single source of truth.