[PL] Vue3 + Zod: brakujące ogniwo
- Opublikowano • 6 min
Wprowadzenie
Celem niniejszego artykułu jest przedstawienie wachlarza możliwości Zod, nie pełny przegląd. Choć Vue.js został wybrany jako przykładowy framework, Zod jest pozbawiony zależności od jakiegokolwiek frameworka. Dla osób korzystających wcześniej z VeeValidate albo Vuelidate, Zod może być nielada odkryciem. Artykuł powstał jako uzupełnienie mini-warsztatu Vue3 + Zod: brakujące ogniwo.
Dlaczego Zod?
Bo ma wiele zastosowań! Wspomniany framework-agnostic nieogranicza się do Vue. Zoda możesz użyć po stronie Frontend i Backend. Możesz go wykorzystać przy walidacji prostych jak i rozbudowanych formularzy; kiedy przygotowujesz payload dla API a także przy sprawdzeniu czy backend zwrócił uzgodniony kontrakt. Mając zdefiniowaną schemę, „za darmo” dostajesz typy TypeScript o czym przekonasz się za chwilę. Kiedy backend wystawia OpenApi możesz na jego podstawie wygenerować schemę oraz typy TypeScript. Zod przydaje się w walidacji wejścia / wyjścia w serwerach MCP.
Zobaczcie sami!
Prosta walidacja
Zdefiniujmy UserSchema:
const UserSchema = z.object({
name: z
.string()
.min(2)
.regex(/^[a-zA-Z]+$/),
age: z.coerce.number().gte(18).lte(120),
nickname: z.string().optional(),
});
const nameSchema = UserSchema.shape.name;
W tej chwili nie interesuje nas nic prócz UserSchema.shape.name, które określa, że pole name powinno mieć minimum 2 znaki, które zaczynają się literą. Mając poniższy formularz
<form novalidate @submit.prevent>
<text-field
v-model="user.name"
:error-message="nameErrorMessage"
@change="validateName"
>
</text-field>
</form>
funkcja validateName otrzymuje aktualną wartość inputa (value).
function validateName(value: string): void {
nameErrorMessage.value = "";
const result = nameSchema.safeParse(value);
if (!result.success) {
nameErrorMessage.value = "Minimum 2 znaki oraz same litery";
}
}
Metoda safeParse (w przeciwieństwie do parse) — nie rzuca wyjątkiem, tylko zwraca obiekt z polem success. Dla uproszczenia wartość nameErrorMessage.value otrzymuje na sztywno przypisany tekst. O automatycznym generowaniu treści błędu jeszcze będzie.
Nie było jeszcze o tych darmowych typach. Proszę bardzo:
type User = z.infer<typeof UserSchema>;
const user: User = {
name: "Mike",
age: 27,
nickname: "Mikey",
};
z.infer<typeof UserSchema> tyle i tylko tyle. Dzięki Zod twój kod jest bardziej przewidywalny w runtime oraz compile-time.
Walidacja całej schemy
Teraz pełniejszy przykład.
const UserSchema = z.object({
name: z
.string()
.min(2)
.regex(/^[a-zA-Z]+$/),
age: z.coerce
.number()
.refine((val) => !isNaN(val) && val >= 18 && val <= 120, {
error: "Podaj w zakresie 18-120",
}),
nickname: z.string().optional(),
});
const user: User = {
name: "Tomek",
age: 27,
nickname: "Tom",
};
pola name oraz age są wymagane. Pole age korzysta z metody refine(), ktore pozwala dodać dowolną logikę walidacyjną z własnym komunikatem błędu — przydaje się, gdy wbudowane metody nie wystarczają. Podpięta funkcja onSubmit() wygląda tak:
function onSubmit() {
const result = UserSchema.safeParse(user);
if (!result.success) {
const flatErrors = z.flattenError(result.error).fieldErrors;
errorMessages.value = Object.values(flatErrors).flat();
}
}
Wykorzystano tutaj z.flattenError(). Moim zdaniem Api do formatowania errorów nie jest wygodne jeżeli mówimy o typowej pracy nad UI. Poniżej przykład surowego wyjścia.
console.log(result.error.issues);
[
{
origin: "string",
code: "too_small",
minimum: 2,
inclusive: true,
path: ["name"],
message: "Za mała wartość: oczekiwano, że string będzie mieć >=2 znaków",
},
{
origin: "string",
code: "invalid_format",
format: "regex",
pattern: "/^[a-zA-Z]+$/",
path: ["name"],
message:
"Nieprawidłowy ciąg znaków: musi odpowiadać wzorcowi /^[a-zA-Z]+$/",
},
{
code: "custom",
path: ["age"],
message: "Podaj w zakresie 18-120",
},
];
Wskazówka: Zod obsługuje locale:
<script setup lang="ts">
import { z } from 'zod'
import { pl } from "zod/locales"
z.config(pl());
</script>
Integracja z VeeValidate
Po krótkim wprowadzeniu, przejdźmy do integracji z VeeValidate. Obecnie stabilny VeeValidate działa z Zod w wersji ^3.24.0. Zod v4 wyszedł w lipcu 2025 (v4.0.1).
Nowy Zod pomijając oficjalne benchmarki i poprawione API ma o lepszą dokumentację. Ważna uwaga: ten artykuł był napisany pod następujące wersje paczek:
"zod": "^4.3.5"
"vee-validate": "^5.0.0-beta.0"
Zacznijmy od setupu. Schema pozostaje ta sama co w sekcji Walidacja całej schemy.
const { meta, errors, handleSubmit, defineField } = useForm<User>({
validationSchema: UserSchema,
initialValues: user,
});
const [name] = defineField("name");
const [age] = defineField("age");
const [nickname] = defineField("nickname");
Z VeeValidate v5 (obecnie beta) nie ma już potrzeby używać toTypedSchema — schemę Zod przekazujemy bezpośrednio jako validationSchema. Zwróćmy uwagę, że VeeValidate robi za nas całą robotę jeżeli chodzi o synchronizowanie stanu oraz wyświetlanie błędów.
<form @submit="onSubmit" :class="{ 'is-invalid': !meta.valid }" novalidate>
<text-field
v-model="name"
:error-message="errors.name"
label="Imię"
></text-field>
<text-field
v-model="age"
:error-message="errors.age"
label="Wiek"
type="number"
></text-field>
<text-field v-model="nickname" label="Pseudonim"></text-field>
<button type="submit" @click="onSubmit">Wyślij</button>
</form>
Błędy per pole (errors.name, errors.age) są dostępne od razu, bez ręcznego wyciągania ich z result.error, jak to miało miejsce wcześniej.
Komponent <text-field /> jest prostym wrapperem na <input /> i nie ma większego znaczenia dla zrozumienia walidacji.
Schema Zod na podstawie OpenApi
Czasami schema jest narzucona z góry i to nawet lepiej, bo kontrakt pomiędzy frontendem a backendem jest spisany. Możemy przetworzyć wystawiony OpenApi, zbudować na tej podstawie schemę Zod; za darmo dostajemy też typy języka TypeScript. Jeżeli jest potrzeba, można schemę rozszerzyć na potrzeby np. edycji bądź tworzenia nowej encji. Schema może być współdzielona przez frontend i backend. W kontekście stacku Nuxt / Vue współdzielona schema może być bardzo przydatna.
Ale do rzeczy. Mi do gustu przypadło Hey API, bo generowało to co chciałem: definicje, pomijając zapytania oraz odpowiedzi.
Najpierw package.json:
{
"name": "vue-zod",
"type": "module",
"scripts": {
"generate:types": "npx @hey-api/openapi-ts"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.91.1"
}
}
Dodajemy konfigurację:
export default {
input: "./src/openapi/openapi.yaml",
output: {
fileName: "{{name}}",
path: "./src/generated",
},
plugins: [
{
name: "zod",
definitions: true,
requests: false,
responses: false,
dates: {
offset: true,
},
},
],
};
Na podstawie przykładowego OpenApi po wykonaniu
npm run generate:types
otrzymamy wygenerowaną schemę. Zwróć uwagę na
definitions: true
w konfiguracji. Wygenerowaliśmy jedynie definicje, pomijając requests oraz responses.
Wejście (OpenApi):
openapi: 3.1.1
info:
title: User API
version: 1.0.0
description: API for managing users
paths:
/users:
get:
summary: Get all users
operationId: getUsers
responses:
"200":
description: List of users
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
post:
summary: Create a new user
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/User"
responses:
"201":
description: User created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"400":
description: Invalid input
/user/{id}:
get:
summary: Get user by ID
operationId: getUserById
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: User ID
responses:
"200":
description: User found
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"404":
description: User not found
components:
schemas:
User:
type: object
required:
- name
- age
properties:
name:
type: string
minLength: 2
pattern: "^[a-zA-Z]+$"
description: User name (letters only, minimum 2 characters)
age:
type: integer
minimum: 18
maximum: 120
description: User age (must be between 18 and 120)
nickname:
type: string
description: Optional user nickname
daje nam po przetworzeniu. Dla mnie bomba!
// This file is auto-generated by @hey-api/openapi-ts
import { z } from "zod";
export const zUser = z.object({
name: z
.string()
.min(2)
.regex(/^[a-zA-Z]+$/),
age: z.int().gte(18).lte(120),
nickname: z.optional(z.string()),
});
Idąc dalej w abstrakcji, ponieważ OpenApi jest świetnie ustandaryzowane, na podstawie języka naturalnego modele AI pomogą nam w utworzeniu specyfikacji. Na podstawie tejże specyfikacji droga do posiadania typów i schema w Zod jest bardzo prosta.
Na koniec krótko o Zodzie w MCP (Model Context Protocol).
Zod w serwerach MCP
W świecie AI wszystko zmienia się jak w kalejdoskopie, a MCP to jedna z nowinek. Na podstawie przykładów, które widziałem oraz kursu Complete Intro to MCP Briana Holta (polecam kursy tego Pana!) wydaje się, że Zod zadomowił się w serwerach MCP. Mogę się mylić. Niemniej weźmy przykład ze wspomnianego kursu i zobaczmy jak możemy wykorzystać Zoda do walidacji wejścia/wyjścia:
import {
McpServer,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Create an MCP server
const server = new McpServer({
name: "add-server",
version: "1.0.0",
});
// Add an addition tool
server.registerTool(
"add",
{
title: "Addition Tool",
description: "Add two numbers",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => {
return {
content: [{ type: "text", text: String(a + b) }],
};
},
);
// Start receiving messages on stdin and sending messages on stdout
const transport = new StdioServerTransport();
await server.connect(transport);
inputSchema przyjmuje bezpośrednio obiekt ze schematami Zod — SDK automatycznie konwertuje je do formatu JSON Schema wymaganego przez protokół MCP. Dzięki temu walidacja wejścia narzędzia jest deklaratywna i typesafe.
Podsumowanie
Jak wspomniałem na początku, chciałem jedynie przedstawić wachlarz możliwości Zoda. Choć artykuł nie jest krótki, to przykłady to zaledwie wierzchołek góry możliwości, które daje nam Zod. Osobiście dotychczasowe narzędzia do walidacji, mocno związane z Vue mocno mnie ograniczały i narzucały konwencje. Zod jako narzędzie uniwersalne rozwiązuje te bolączki. Mam nadzieję, że następnym razem jak będziesz w zespole omawiać alternatywy dla obecnych w Twoim systemie walidatorów, weźmiesz pod uwagę Zoda.