Quick Link: If you're here for the code, skip the backstory and jump to the code section.
The Challenge
In my recent project, I faced a unique challenge while working with two different validation tools: class-validator
and zod
. My workflow involved using class-validator
to validate DTOs received from POST requests, and then zod
for additional schema parsing before sending the data to an external service.
The specific issue emerged with a DTO that utilized the IsISO8601()
decorator from class-validator
. This DTO was then re-validated using the z.string().datetime()
function in zod
. While IsISO8601()
recognized 2020-07-10 15:00:00.000
as a valid ISO 8601 date, z.string().datetime()
did not, resulting in an error during the secondary parsing process.
I had two choices: allow the DTO to pass through as a simple string or develop a custom decorator in class-validator that aligned with zod's datetime validation.
Intrigued, I delved into the zod source code and extracted the logic for z.string().datetime()
. I then integrated this logic into a custom decorator for class-validator
and applied it to the DTO class. Although this solution was effective, I had concerns about its long-term reliability, especially if zod's validation criteria were updated in the future. This led me to design a more adaptable solution—a custom decorator capable of accepting any zod schema or type for validation purposes.
Now, let's dive into how this solution works, complete with a code example. For context, I'll assume your project is already set up with TypeScript and experimental decorators enabled.
Implementing IsZodType Decorator
First, install the necessary packages:
npm i class-validator@0.14.1 zod@3.22.4
Then, create is-zod-type.ts
and add the following:
import { ZodType } from 'zod';
import { ValidateBy, ValidationOptions, ValidationArguments, buildMessage } from 'class-validator';
const IS_ZOD_TYPE = 'isZodType';
function isZodType(
value: unknown,
zodType: ZodType,
validationArguments?: ValidationArguments
): boolean {
const parseResult = zodType.safeParse(value);
if (!parseResult.success) {
validationArguments!.constraints[1] = parseResult.error.issues
.map(
(e) => `${e.path.join(' - ').trim() !== '' ? `${e.path.join(' - ')}: ` : ''}${e.message}`
)
.join(', ');
}
return parseResult.success;
}
export function IsZodType(zodType: ZodType, validationOptions?: ValidationOptions) {
return ValidateBy(
{
name: IS_ZOD_TYPE,
constraints: [zodType],
validator: {
validate: (value, args): boolean => isZodType(value, args?.constraints[0], args),
defaultMessage: buildMessage(
(eachPrefix) => eachPrefix + '$constraint2',
validationOptions
),
},
},
validationOptions
);
}
This code defines a constant and two functions, adhering to the class-validator
pattern for creating validators. The isZodType
function is responsible for the actual validation, employing zodType.safeParse
to parse the value. Any parsing errors are captured for later use in constructing a detailed validation error message. The function ultimately returns the success status of the validation.
The IsZodType
decorator, which we can apply in our DTO classes, invokes the isZodType
validation function and also provides a mechanism for building an error message in case of parsing errors.
Demonstrating the Problem and the Solution
Consider this example and try to identify where the error might occur:
import { z } from 'zod';
import { IsISO8601, IsString, validateSync } from 'class-validator';
const songSchema = z.object({
title: z.string(),
artist: z.string(),
releaseTimestamp: z.string().datetime(),
});
class Song {
@IsString()
title!: string;
@IsString()
artist!: string;
@IsISO8601()
releaseTimestamp!: string;
}
const songFromClassValidator = new Song();
songFromClassValidator.title = 'Liar';
songFromClassValidator.artist = 'Yngwie Malmsteen';
songFromClassValidator.releaseTimestamp = '2020-07-10 15:00:00.000';
const classValidatorResult = validateSync(songFromClassValidator);
console.log('classValidatorResult => ', classValidatorResult);
const songFromZodSchema = songSchema.safeParse(songFromClassValidator);
console.log('zodParseResult => ', JSON.stringify(songFromZodSchema));
Running this code will produce the following output:
classValidatorResult => []
zodParseResult => {"success":false,"error":{"issues":[{"code":"invalid_string","validation":"datetime","message":"Invalid datetime","path":["releaseTimestamp"]}],"name":"ZodError"}}
The class validator accepts the releaseTimestamp
string as a valid ISO8601 format, but zod
does not. Let's modify the code to utilize our new decorator and resolve this discrepancy:
import { z } from 'zod';
import { IsString, validateSync } from 'class-validator';
import { IsZodType } from './is-zod-type';
const songSchema = z.object({
title: z.string(),
artist: z.string(),
releaseTimestamp: z.string().datetime(),
});
class Song {
@IsString()
title!: string;
@IsString()
artist!: string;
@IsZodType(songSchema.shape.releaseTimestamp)
releaseTimestamp!: string;
}
const songFromClassValidator = new Song();
songFromClassValidator.title = 'Liar';
songFromClassValidator.artist = 'Yngwie Malmsteen';
songFromClassValidator.releaseTimestamp = '2020-07-10 15:00:00.000';
const classValidatorResult = validateSync(songFromClassValidator);
console.log('classValidatorResult => ', classValidatorResult);
const songFromZodSchema = songSchema.safeParse(songFromClassValidator);
console.log('zodParseResult => ', JSON.stringify(songFromZodSchema));
By using the IsZodType
decorator and passing in the releaseTimestamp
from our zod schema (songSchema
), we can now achieve consistent validation results. The output should now reflect this:
classValidatorResult => [
ValidationError {
target: Song {
title: 'Liar',
artist: 'Yngwie Malmsteen',
releaseTimestamp: '2020-07-10 15:00:00.000'
},
value: '2020-07-10 15:00:00.000',
property: 'releaseTimestamp',
children: [],
constraints: { isZodType: 'Invalid datetime' }
}
]
zodParseResult => {"success":false,"error":{"issues":[{"code":"invalid_string","validation":"datetime","message":"Invalid datetime","path":["releaseTimestamp"]}],"name":"ZodError"}}
That's it! We've successfully created a custom class-validator
decorator that accepts zod schema types. This ensures that our DTOs are consistently validated, allowing for confident further processing with zod
.
Conclusion
This scenario might be uncommon, but exploring the capabilities of class-validator
and zod
has been both educational and enjoyable. It highlights the potential for cool solutions in specialized cases.
References: