[PL] Vue3 + Zod: brakujące ogniwo

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;
Formularz przed edycją
Formularz przed edycją
Formularz po walidacji
Formularz po walidacji

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();
  }
}
Formularz po walidacji
Formularz po walidacji

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>
VueValidate korzystający z walidacji Zoda

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.