schemulous
is a robust and flexible schema definition and validation library for TypeScript, tailored to work seamlessly with OpenAPI version 3 specifications. While defining your schemas for validation, you're also preparing them for OpenAPI documentation, streamlining two processes into one.
- Define robust schemas with a fluent API.
- Seamless integration with OpenAPI version 3 specifications.
- Comprehensive validation with detailed error messages.
- Full support for TypeScript's type inference.
- Highly customizable to fit your specific needs.
- Zero dependencies for a clean and efficient setup.
- Inspired by
zod
, offering a familiar experience with enhanced OpenAPI capabilities. - A minimalist version available for those who want to import only the features they need.
Install schemulous
via npm:
npm install schemulous
import { string, number, boolean, object, enumType, array } from 'schemulous';
const UserSchema = object({
name: string().minLength(3).maxLength(50),
age: number().int().minimum(18).maximum(100),
email: string().email(),
birthDate: string().date(),
status: enumType(['active', 'inactive', 'suspended']).default('suspended'),
isPremiumMember: boolean(),
hobbies: array(string()),
weight: number().minimum(0).exclusiveMinimum().meta({
title: 'Weight',
description: 'User\'s weight in kilograms, which must be greater than 0.',
example: 65
}),
height: number().nullable(),
nickname: string().optional(),
createdAt: string().dateTime().meta({ readOnly: true }),
updatedAt: string().dateTime().meta({ readOnly: true }),
});
One of the powerful features of schemulous
is its ability to infer TypeScript types directly from your schema definitions. This ensures that your data structures and their validations are always in sync, reducing potential type-related issues in your codebase.
To infer a type from a schema, you can use the Infer
utility:
import { type Infer } from 'schemulous';
type User = Infer<typeof UserSchema>;
With the above line, a new type named User
is created. This type will have all the properties and constraints defined in UserSchema
. Any changes made to the schema will automatically reflect in the User
type, ensuring consistency and reducing manual type updates.
If you were to manually define the User
type, it would look something like this:
type User = {
name: string;
age: number;
email: string;
birthDate: string;
status: "active" | "inactive" | "suspended";
isPremiumMember: boolean;
hobbies: string[];
weight: number;
height: number | null;
nickname: string | undefined;
createdAt: string;
updatedAt: string;
}
This inferred type can then be used throughout your codebase wherever you need to reference the structure of a user, be it in function parameters, class properties, or elsewhere.
Using the toOpenApi
function, you can convert the defined schema into an OpenAPI representation:
import { toOpenApi } from 'schemulous';
const OpenApiRepresentation = toOpenApi(UserSchema);
console.log(OpenApiRepresentation);
The output will be:
{
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 3, "maxLength": 50 },
"age": { "type": "integer", "minimum": 18, "maximum": 100 },
"email": { "type": "string", "format": "email" },
"birthDate": { "type": "string", "format": "date" },
"status": {
"type": "string",
"enum": ["active", "inactive", "suspended"],
"default": "suspended"
},
"isPremiumMember": { "type": "boolean" },
"hobbies": { "type": "array", "items": { "type": "string" } },
"weight": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": true,
"title": "Weight",
"description": "User's weight in kilograms, which must be greater than 0.",
"example": 65
},
"height": { "type": "number", "nullable": true },
"nickname": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time", "readOnly": true },
"updatedAt": { "type": "string", "format": "date-time", "readOnly": true }
},
"required": ["name", "age", "email", "birthDate", "status", "isPremiumMember", "hobbies", "weight", "height", "createdAt", "updatedAt"]
}
To validate data against your schema, you can use the parse
method:
import { ValidationError } from 'schemulous';
const data = {
name: "John Doe",
age: 25,
email: "[email protected]",
birthDate: "1996-05-15",
status: "active",
isPremiumMember: true,
hobbies: ["reading", "hiking", "swimming"],
weight: 70,
height: 175,
nickname: "Johnny",
createdAt: "2022-01-01T12:00:00Z",
updatedAt: "2022-01-02T12:00:00Z",
};
try {
const validData = UserSchema.parse(data);
// Here, validData contains the parsed data that adheres to the defined schema.
console.log(validData);
} catch (error) {
if (error instanceof ValidationError) {
console.error(error.issues);
}
}
If you prefer not to use try-catch, you can utilize the safeParse
method:
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data);
} else {
console.error(result.error.issues);
}
The safeParse
method returns either a SafeParseSuccess
object containing the validated data or a SafeParseError
object detailing the validation issues.
const invalidUserData = {
name: "Jo", // Too short, minimum length is 3
age: 15, // Below the minimum age of 18
email: "not-an-email", // Not a valid email format
// ... other fields
};
const result = UserSchema.safeParse(invalidUserData);
if (!result.success) {
console.log(result.error.issues);
/*
[ { code: 'too_small',
inclusive: true,
message: 'String must contain at least 3 character(s)',
minimum: 3,
path: [ 'name' ],
type: 'string' },
{ code: 'too_small',
inclusive: true,
message: 'Number must be greater than or equal to 18',
minimum: 18,
path: [ 'age' ],
type: 'number' },
{ code: 'invalid_string',
message: 'Invalid email',
path: [ 'email' ],
validation: 'email' } ]
*/
}
schemulous
provides default error messages for validations, but there might be cases where you'd want to customize these messages to better fit your application's context or to provide more user-friendly feedback.
For instance, when using the minLength
validator on a string()
, you can provide a custom error message in two ways:
- Directly passing a string.
- Using a function that returns a string, which allows for dynamic error messages based on the input value.
Let's see how to use these with the minLength
validator:
const NameSchema = string().minLength(5, "Name must be at least 5 characters long.");
const NameSchema = string().minLength(5, (value) => `${value} is too short. Please provide a name with at least 5 characters.`);
In the second example, the error message will include the actual input value, making the feedback more specific to the user's input.
By customizing error messages, you can ensure that your application provides clear and actionable feedback to users, enhancing the overall user experience.
When working with schemulous
, there might be scenarios where you want to reuse an existing schema but with slight modifications. In such cases, it's essential to be aware of the mutable nature of schemas. Directly chaining methods to an existing schema will modify the original schema, which might not be the desired behavior.
To avoid unintentional modifications, schemulous
provides a copy
method. By copying a schema, you can derive a new schema from the original one without affecting the original schema.
- Immutability: Ensure that the original schema remains unchanged when you want to create variations.
- Flexibility: Easily create multiple versions of a schema based on a common base schema.
Let's consider a simple string schema:
const BaseNameSchema = string().minLength(5);
Now, suppose you want a new schema derived from BaseNameSchema
but with an added constraint of a maximum length:
const CopiedNameSchema = BaseNameSchema.copy().maxLength(10);
By using copy()
, BaseNameSchema
remains unaffected, and you have a new schema CopiedNameSchema
with both the minimum and maximum length constraints.
Always remember to use the copy
method when you want to extend or modify an existing schema without altering its original definition. This ensures clarity and avoids potential bugs in your schema definitions.
While the fluent API and method chaining in schemulous
offer a concise and readable way to define schemas, it comes with a trade-off. Every method in the chain, even if not used, gets bundled in the final build, potentially increasing the size of your application.
To cater to developers who prefer a leaner approach, schemulous
offers a minimalist version. Importantly, even in this minimalist version, you can access and utilize all the features of schemulous
. This version allows you to import only the functionalities you need, ensuring a smaller footprint without compromising on the feature set.
-
Core Imports: Instead of importing from
schemulous
, useschemulous/core
for the basic functionalities.import { string, safeParse } from 'schemulous/core';
-
Extensions: For additional methods that were previously available through chaining, you can import them from
schemulous/extensions
.import { minLength } from 'schemulous/extensions';
-
Usage: Instead of chaining, you'll use the imported methods as functions, passing the schema as the first argument.
const NameSchema = minLength(string(), 5);
By adopting this minimalist approach, you can ensure that you're only bundling the functionalities you need, making your application leaner and potentially faster. This is especially beneficial for web applications where every kilobyte matters for performance.
When using the minimalist version, ensure that your project's tsconfig.json
has the moduleResolution
set to either node16
, nodenext
, or bundler
. This is crucial for the correct resolution of modules in the minimalist version.
{
"compilerOptions": {
"moduleResolution": "node16" // or "nodenext" or "bundler"
}
}
Failure to configure this might result in module resolution errors during compilation or runtime.
In many scenarios, the built-in validation methods might not cover all the specific requirements you have for your data. schemulous
provides a flexible way to add custom validation logic to your schemas using the refine
method.
-
Basic Usage: You can use
refine
to add a custom validation function. This function should returntrue
if the value is valid andfalse
otherwise.const NameSchema = string() .minLength(5) .refine((value) => value.startsWith('start-'));
In the above example, the
NameSchema
not only requires the string to be at least 5 characters long but also mandates that it starts with the prefix 'start-'. -
Custom Error Messages: By default, if the custom validation fails, a generic error message is provided. However, you can specify a custom error message by passing it as the second argument to
refine
.const NameSchema = string() .minLength(5) .refine( (value) => value.startsWith('start-'), "Name must start with 'start-' prefix." );
Alternatively, you can provide a function that returns a string, allowing for more dynamic error messages based on the value being validated.
-
OpenAPI Integration: It's important to note that custom validations added with
refine
won't be reflected in the OpenAPI output generated bytoOpenApi
. If you want to document this custom behavior, you can use themeta
method to add relevantformat
ordescription
details.const NameSchema = string() .minLength(5) .refine((value) => value.startsWith('start-')) .meta({ description: "A string that starts with 'start-' and has a minimum length of 5 characters." });
By leveraging the refine
method, you can ensure that your schemas are tailored to your specific needs, providing both robust validation and clear documentation.
In many scenarios, you might want to halt the validation process as soon as a single validation error is encountered, rather than checking all validation rules. The abortEarly
option allows you to do just that.
When you set abortEarly: true
on a schema, the validation process will stop and throw an error immediately upon the first validation failure it encounters.
Here's an example:
const UserSchema = object(
{
name: string().minLength(3),
details: object(
{
email: string().email(),
status: enumType(['active', 'inactive']),
},
{ abortEarly: true }
),
isPremiumMember: boolean(),
},
{ abortEarly: true }
);
In the above example, if the UserSchema
encounters a validation error, it will immediately throw an error without checking the subsequent properties. This is because abortEarly: true
is set on the main schema.
However, it's crucial to understand the scope of abortEarly
. If a property itself has multiple validations (like the details
property in the example), and you want to stop validation on the first error within that property, you must also set abortEarly: true
on that property's schema. Otherwise, all validations for that specific property will be checked before an error is thrown.
In simpler terms, abortEarly
works at the level it's set. If you want immediate error feedback at both the main schema and nested property levels, ensure you set abortEarly: true
at both levels.