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
, and2023-06-30
are valid.2023-02-29
and2023-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
);
}