Custom Valibot Validators

Published: August 25, 2023 Updated: August 27, 2023

Custom Valibot Validators

Table of Contents

What is Valibot?

If you’re here, you probably already know what Valibot (Github) is. But if not, the core function of Valibot is to create a schema. A schema can be compared to a type definition in TypeScript. The big difference is that TypeScript types are “not executed” and are more or less a DX feature. A schema on the other hand, apart from the inferred type definition, can also be executed at runtime to guarantee type safety of unknown data.

The Ultimate ISO Validator

  • The date regex here also validates the correct number of days in a month, including leap year.
  • seconds and milliseconds are optional. seconds must be true for milliseconds to be validated.
  • T is optional if date is not validated.
  • Time zone is optional, and accepts Z or a UTC offset (ex. +/-hh:mm)
import type { PipeResult } from "valibot";

/**
 * Creates a complete, customizable validation function that validates a datetime.
 *
 * The correct number of days in a month is validated, including leap year.
 *
 * Date Format: yyyy-mm-dd
 * Time Formats: [T]hh:mm[:ss[.sss]][+/-hh:mm] or [T]hh:mm[:ss[.sss]][Z]
 *
 * @param {Object} options The configuration options.
 * @param {boolean} options.date Whether to validate the date.
 * @param {boolean} options.time Whether to validate the time.
 * @param {boolean | "optional"} options.seconds Whether to validate the seconds.
 * @param {boolean | "optional"} options.milliseconds Whether to validate the milliseconds.
 * @param {boolean | "optional"} options.timezone Whether to validate the timezone.
 * @param {string} error The error message.
 *
 * @returns A validation function.
 */
export function iso<TInput extends string>(options?: {
	date?: boolean;
	time?: boolean;
	seconds?: boolean | "optional";
	milliseconds?: boolean | "optional";
	timezone?: boolean | "optional";
	error?: string;
}) {
	return (input: TInput): PipeResult<TInput> => {
		const {
			date = true,
			time = true,
			seconds = "optional",
			milliseconds = "optional",
			timezone = "optional",
			error = "Invalid ISO string",
		} = options || {};

		const dateRegex = "((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))";
		const millisecondsRegex = milliseconds ? `(\\.\\d{3})${milliseconds === "optional" ? "?" : ""}` : "";
		const secondsRegex = seconds ? `(:[0-5]\\d${millisecondsRegex})${seconds === "optional" ? "?" : ""}` : "";
		const timezoneRegex = timezone ? `([+-]([01]\\d|2[0-3]):[0-5]\\d|Z)${timezone === "optional" ? "?" : ""}` : "";
		const timeRegex = `([01]\\d|2[0-3]):[0-5]\\d${secondsRegex}${timezoneRegex}`;
		const regex = new RegExp(`^${date ? dateRegex : ""}${date && time ? "T" : time ? "T?" : ""}${time ? timeRegex : ""}$`);

		if (!regex.test(input)) {
			return {
				issues: [
					{
						validation: "iso",
						message: error,
						input
					}
				]
			};
		}
		return { output: input };
	};
}

Valid Examples

"2023-08-05T12:24:59.000Z"; 			// options undefined
"2023-08-05T12:24:59.000-05:00"; 	// options undefined
"2023-08-05T12:24-05:00"; 				// options undefined

// seconds, milliseconds, and timezone are optional by default, or you can explicitly exclude them
"2023-08-05T12:24"; // { seconds: false, timezone: false }

// date and time are required by default, but you can exclude them
"2023-08-05"; 		// { time: false }
"T12:24:59.000Z"; // { date: false }

// If date is excluded, the T is optional
"12:24"; // { date: false, seconds: false, timezone: false }

The number of days in a month are also validated, including leap year.

  • 2023-02-28, 2024-02-29, and 2023-06-30 are valid.
  • 2023-02-29 and 2023-06-31 are invalid.

Using the Validator

Here is an example using the iso() validator. This date schema will validate the Date constructor input and output a valid Date object.

// Transforms a Date, string, or number into a Date
export const dateSchema = transform(
	// Input types: Date, string, number
	union(
		[date(), string([iso()]), number([minValue(0)])], 
		"Must be a valid Date object, ISO string, or UNIX timestamp"
	),
	// Output type: Date
	(input) => new Date(input)
);

The Entire RegEx (Uncut)

This is the entire uncut regex. It behaves the same as the validator if no options were defined.

/^((\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|(02)-(0[1-9]|1\d|2[0-8])))T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{3})?)?([+-]([01]\d|2[0-3]):[0-5]\d|Z)?$/

Custom withDefault

The built-in validator only uses the default value if the input is undefined. This one coerces the value if the input is an empty string (trimmed), NaN, or falsy. I believe this fits more use cases for a default value.

export function withDefault<TSchema extends BaseSchema>(schema: TSchema, value: Input<TSchema>) {
	return coerce(schema, (input) =>
		typeof value === "string" 
			? `${input}`.trim() || value 
			: !input || (typeof value == "number" && isNaN(Number(input))) 
			? value 
			: input
	);
}