Ein großer Vorteil von TypeScript im Backend ist die Möglichkeit, Typen und Interfaces zwischen Frontend und Backend zu teilen. Dies wird in einem Nx Workspace besonders einfach durch den Einsatz von Shared Libs. Somit kann sichergestellt werden, dass auf der Typenebene nicht viel schiefgehen kann. Da die Typenprüfung von TypeScript aber zur Laufzeit nicht mehr existiert und Daten bei REST in der Regel mittels untypisiertem JSON übertragen werden, sollte zusätzlich eine Validierung der Daten am Backend stattfinden. Validierung am Backend ist wichtig für die Sicherheit der Schnittstelle und damit der gesamten Anwendung. Zusätzlich können über die Validierung bereits fachlich ungültige Daten ausgefiltert oder abgelehnt werden.
In diesem Artikel wird gezeigt, wie mit der Library zod ein alternativer Ansatz zur Built-In ValidationPipe
von Nest.js zur Validierung von Daten implementiert werden kann.
Die Nest.js ValidationPipe
Nest.js bietet bereits die Built-In ValidationPipe
an. Diese setzt auf die npm-Libraries class-validator und class-transformer und verfolgt damit einen objektorientierten und deklarativen Ansatz. Die Validierung wird definiert, indem zuerst die Datenstruktur modelliert wird. Im Anschluss können die Validierungsinformationen dann mittels Decorators als Metadaten bereitgestellt werden.
Beispiel:
import { IsBoolean, IsDateString, IsString, MaxLength, MinLength } from 'class-validator';
export class FlightDto {
@IsString()
@MinLength(5)
@MaxLength(100)
from: string;
@IsString()
@MinLength(5)
@MaxLength(100)
to: string;
@IsDateString()
time: string;
@IsBoolean()
delayed: boolean;
}
In einem Nest.js Controller findet dann die Validierung wie folgt statt:
import { Body, Controller, Get, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { FlightService } from './flight.service';
import { FlightDto } from '@angular-nestjs-zod/shared/util/api-models';
@Controller('flights')
export class FlightController {
constructor(private readonly flightService: FlightService) {}
@Get()
getFlights(): FlightDto[] {
return this.flightService.getAllFlights();
}
@Post()
@UsePipes(new ValidationPipe({ transform: true }))
createFlight(@Body() flight: FlightDto): void {
this.flightService.createFlight(flight);
}
}
Für eine solche Verwendung im TypeScript strict mode muss die tsconfig.json
unter compilerOptions
mit "strictPropertyInitialization": false
ergänzt werden.
Durch den deklarativen Ansatz ist der Code sehr gut lesbar. Die eingebaute Integration in Nest.js macht die Validierung sehr leicht. Jedoch hat dieser Ansatz auch Nachteile. Zum Beispiel lassen sich die oben definierten DTO-Klassen nur eingeschränkt im Frontend benutzen, da class-validator und class-transformer eine unverhältnismäßige Erhöhung der Bundle-Size mit sich bringen und sich somit negativ auf die Performance auswirken können. Zusätzlich wird von einem Einsatz von Klassen in vielen Frontend State Management Libraries abgeraten, um die Serialisierbarkeit der Daten sicherzustellen. Dies gilt zum Beispiel auch für die State-Management-Library NgRx in Angular.
Nest.js-Validierung mit zod
Seit einiger Zeit erhält die Validation Library zod in der TypeScript-Community viel Aufmerksamkeit. Sie erlaubt einen umgekehrten Ansatz, indem sie uns ermöglicht, den Validierungs-Code zuerst zu schreiben. Die Modellierung der Typen erledigt die Library dann vollautomatisch. Dazu nutzt sie fortgeschrittene TypeScript-Features und erstellt die Typen aus unserem Validierungs-Code. Dies kann für unser Beispiel so aussehen:
import { z } from 'zod';
export const flightSchema = z
.object({
from: z.string().min(5).max(100),
to: z.string().min(5).max(100),
time: z.string().datetime(),
delayed: z.boolean(),
})
.required();
export type Flight = z.infer<typeof flightSchema>;
Wir definieren also mittels der von zod importierten Funktionen die gesamte Beschaffenheit unseres Objektes und reichern diese im selben Schritt direkt mit Validierungsinformationen an. Den Typen kann uns zod am Ende mittels der Hilfsfunktion z.infer
automatisch generieren. Der Typ Flight
lässt sich nun über eine Nx lib teilen und somit im Frontend und Backend verwenden. So können wir zum Beispiel in einem Angular-Frontend einen neuen Flug wie folgt anlegen:
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Flight } from '@angular-nestjs-zod/shared/util/api-models';
@Injectable({
providedIn: 'root',
})
export class FlightService {
private readonly httpClient = inject(HttpClient);
getFlights(): Observable<Flight[]> {
return this.httpClient.get<Flight[]>('/api/flights');
}
createFlight(newFlight: Flight): Observable<Flight> {
return this.httpClient.post<Flight>('/api/flights', newFlight);
}
}
TypeScript kann nun auf der Typenebene sicherstellen, dass nur ein korrekter Flug übergeben werden kann:
Ein entscheidender Teil fehlt nun aber noch. Damit die Validierung zur Laufzeit passieren und auch Regeln wie minLength und maxLength greifen können, muss eine eigene ValidationPipe implementiert und registriert werden. Dies ist mit wenigen Zeilen Code erledigt:
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { ZodSchema } from 'zod';
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodSchema) {}
transform(value: unknown) {
try {
return this.schema.parse(value);
} catch (error) {
throw new BadRequestException(error);
}
}
}
Im Controller wird die Pipe nun wie folgt eingebunden, um die Validierung für einen bestimmten Request zu aktivieren:
import { Body, Controller, Get, Post, UsePipes } from '@nestjs/common';
import { FlightService } from './flight.service';
import { ZodValidationPipe } from '../common/zod-validation-pipe';
import { Flight, flightSchema } from '@angular-nestjs-zod/shared/util/api-models';
@Controller('flights')
export class FlightController {
constructor(private readonly flightService: FlightService) {}
@Get()
getFlights(): Flight[] {
return this.flightService.getAllFlights();
}
@Post()
@UsePipes(new ZodValidationPipe(flightSchema))
createFlight(@Body() flight: Flight): void {
this.flightService.createFlight(flight);
}
}
Wenn wir nun ein invalides Objekt an unsere API senden, können wir folgenden Fehler sehen:
Dies beweist, dass die Validierung sowohl auf der Typenebene als auch zur Laufzeit funktioniert.
Fazit
Die Library zod ermöglicht es, Typen und Validierungsregeln in einem Schritt auf eine schlanke, einfache und gut lesbare Art und Weise zu erstellen. Mit wenigen Zeilen Code kann zod auch mit Nest.js benutzt werden. Dies kann die Sicherheit und Stabilität von Anwendungen deutlich erhöhen. Besonders Applikationen mit aufwändiger fachlicher Logik, die viel Validierung verlangt, können davon profitieren. Zusätzlich ist die Erhöhung der Frontend Bundle-Size durch zod in der Regel deutlich geringer als durch den Einsatz von class-validator und class-transformer.
Quellen
- Nest.js Dokumentation - ValidationPipe
- Nest.js Dokumentation - Custom Validation with zod
- zod Dokumentation
- Bundlephobia class-validator
- Bundlephobia class-transformer
- Bundlephobia zod
Autoren
- Thomas Enderle
- Marco Hämmerle
- Florian Tischler