1208 lines
38 KiB
TypeScript
1208 lines
38 KiB
TypeScript
import { err, Result } from "@shared/utils/result.ts";
|
|
import { ok } from "@shared/utils/index.ts";
|
|
import { None, none, Option, some } from "@shared/utils/option.ts";
|
|
// ── Error Types ─────────────────────────────────────────────────────
|
|
type ValidationErrorDetail =
|
|
| {
|
|
kind: "typeMismatch";
|
|
expected: string;
|
|
received: string;
|
|
msg?: string;
|
|
}
|
|
| {
|
|
kind: "propertyValidation";
|
|
property: string;
|
|
detail: ValidationErrorDetail;
|
|
msg?: string;
|
|
}
|
|
| {
|
|
kind: "missingProperties" | "unexpectedProperties";
|
|
keys: string[];
|
|
msg?: string;
|
|
}
|
|
| {
|
|
kind: "unionValidation";
|
|
details: ValidationErrorDetail[];
|
|
msg?: string;
|
|
}
|
|
| {
|
|
kind: "arrayElement";
|
|
index: number;
|
|
detail: ValidationErrorDetail;
|
|
msg?: string;
|
|
}
|
|
| { kind: "general"; mark?: string; msg: string };
|
|
|
|
class SchemaValidationError extends Error {
|
|
public readonly type = "SchemaValidationError";
|
|
|
|
constructor(
|
|
public readonly input: unknown,
|
|
public readonly detail: ValidationErrorDetail,
|
|
) {
|
|
super(detail.msg || "Schema validation error");
|
|
}
|
|
|
|
public format(): Record<string, unknown> {
|
|
return {
|
|
input: this.input,
|
|
error: SchemaValidationError.formatDetail(this.detail),
|
|
};
|
|
}
|
|
|
|
get msg(): string {
|
|
return SchemaValidationError.getBestMsg(this.detail);
|
|
}
|
|
|
|
private static getBestMsg(detail: ValidationErrorDetail): string {
|
|
switch (detail.kind) {
|
|
case "typeMismatch":
|
|
case "unexpectedProperties":
|
|
case "missingProperties":
|
|
case "general":
|
|
case "unionValidation":
|
|
return SchemaValidationError.formatMsg(detail);
|
|
case "propertyValidation":
|
|
case "arrayElement":
|
|
return detail.msg ||
|
|
SchemaValidationError.getBestMsg(detail.detail);
|
|
default:
|
|
return "Unknown error";
|
|
}
|
|
}
|
|
|
|
private static formatDetail(detail: ValidationErrorDetail): any {
|
|
switch (detail.kind) {
|
|
case "general":
|
|
case "typeMismatch":
|
|
return SchemaValidationError.formatMsg(detail);
|
|
case "propertyValidation":
|
|
return {
|
|
[detail.property]: detail.msg ||
|
|
this.formatDetail(detail.detail!),
|
|
};
|
|
case "unexpectedProperties":
|
|
case "missingProperties": {
|
|
const msg = detail.msg ||
|
|
(detail.kind === "unexpectedProperties"
|
|
? "Property is not allowed in a strict schema object"
|
|
: "Property is required, but missing");
|
|
|
|
return detail.keys.reduce<Record<string, string>>(
|
|
(acc, key) => {
|
|
acc[key] = msg;
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
case "arrayElement": {
|
|
const detailObj: Record<string, string> = {};
|
|
if (detail.msg) {
|
|
detailObj["msg"] = detail.msg;
|
|
}
|
|
detailObj[`index_${detail.index}`] = this.formatDetail(
|
|
detail.detail,
|
|
);
|
|
return detailObj;
|
|
}
|
|
case "unionValidation": {
|
|
const arr: unknown[] = detail.details?.map(
|
|
(err): unknown => this.formatDetail(err),
|
|
);
|
|
if (detail.msg) {
|
|
arr.unshift("Msg: " + detail.msg);
|
|
}
|
|
return arr;
|
|
}
|
|
default:
|
|
return "Unknown error type";
|
|
}
|
|
}
|
|
|
|
private static formatMsg(detail: ValidationErrorDetail): string {
|
|
if (detail.msg || detail.kind === "general") {
|
|
return detail.msg || "Unknown error";
|
|
}
|
|
switch (detail.kind) {
|
|
case "typeMismatch":
|
|
return `Expected ${detail.expected}, but received ${detail.received}`;
|
|
case "unexpectedProperties":
|
|
return `Properties not allowed: ${detail.keys.join(", ")}`;
|
|
case "missingProperties":
|
|
return `Missing required properties: ${detail.keys.join(", ")}`;
|
|
case "unionValidation":
|
|
return `Input did not match any union member`;
|
|
default:
|
|
return "Unknown error";
|
|
}
|
|
}
|
|
}
|
|
|
|
function createValidationError(
|
|
input: unknown,
|
|
error: ValidationErrorDetail,
|
|
) {
|
|
return new SchemaValidationError(input, error);
|
|
}
|
|
|
|
type PrimitiveTypeName =
|
|
| "string"
|
|
| "number"
|
|
| "bigint"
|
|
| "boolean"
|
|
| "symbol"
|
|
| "undefined"
|
|
| "object"
|
|
| "function";
|
|
|
|
// ── Core Schema Types ───────────────────────────────────────────────
|
|
export interface Schema<T> {
|
|
parse(input: unknown): Result<T, SchemaValidationError>;
|
|
}
|
|
|
|
type ValidationCheck<T> = (value: T) => SchemaValidationError | void;
|
|
|
|
export abstract class BaseSchema<T> implements Schema<T> {
|
|
protected checks: ValidationCheck<T>[] = [];
|
|
|
|
constructor(public readonly msg?: string) {}
|
|
|
|
public parse(input: unknown): Result<T, SchemaValidationError> {
|
|
return this.validateInput(input).andThen((value) =>
|
|
this.applyValidationChecks(value)
|
|
);
|
|
}
|
|
|
|
protected abstract validateInput(
|
|
input: unknown,
|
|
): Result<T, SchemaValidationError>;
|
|
|
|
protected static validatePrimitive<U>(
|
|
input: unknown,
|
|
expectedType: PrimitiveTypeName,
|
|
msg?: string,
|
|
): Result<U, SchemaValidationError> {
|
|
const receivedType = typeof input;
|
|
return receivedType === expectedType ? ok(input as U) : err(
|
|
createValidationError(input, {
|
|
kind: "typeMismatch",
|
|
expected: expectedType,
|
|
received: receivedType,
|
|
msg: msg,
|
|
}),
|
|
);
|
|
}
|
|
|
|
public addCheck(check: ValidationCheck<T>): this {
|
|
this.checks.push(check);
|
|
return this;
|
|
}
|
|
|
|
protected applyValidationChecks(
|
|
value: T,
|
|
): Result<T, SchemaValidationError> {
|
|
for (const check of this.checks) {
|
|
const error = check(value);
|
|
if (error) {
|
|
return err(error);
|
|
}
|
|
}
|
|
return ok(value);
|
|
}
|
|
|
|
protected static isNullishSchema(schema: Schema<any>): boolean {
|
|
return schema.parse(null).isOk() || schema.parse(undefined).isOk();
|
|
}
|
|
}
|
|
|
|
class StringSchema extends BaseSchema<string> {
|
|
private static readonly emailRegex =
|
|
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; // https://stackoverflow.com/questions/46155/how-can-i-validate-an-email-address-in-javascript
|
|
|
|
private static readonly ipRegex =
|
|
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; // https://stackoverflow.com/questions/4460586/javascript-regular-expression-to-check-for-ip-addresses
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<string, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<string>(
|
|
input,
|
|
"string",
|
|
this.msg,
|
|
);
|
|
}
|
|
|
|
public max(len: number, msg?: string): this {
|
|
return this.addCheck((value) => {
|
|
if (value.length > len) {
|
|
return createValidationError(value, {
|
|
kind: "general",
|
|
msg: msg || `String must be at most ${len} characters long`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public min(len: number, msg?: string): this {
|
|
return this.addCheck((value) => {
|
|
if (value.length < len) {
|
|
return createValidationError(value, {
|
|
kind: "general",
|
|
msg: msg || `String must be at most ${len} characters long`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public regex(pattern: RegExp, msg?: string): this {
|
|
return this.addCheck((value) => {
|
|
if (!pattern.test(value)) {
|
|
return createValidationError(value, {
|
|
kind: "general",
|
|
msg: msg || `String must match pattern ${String(pattern)}`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public email(msg?: string): this {
|
|
return this.regex(
|
|
StringSchema.emailRegex,
|
|
msg || "String must be a valid email address",
|
|
);
|
|
}
|
|
|
|
public ip(msg?: string): this {
|
|
return this.regex(
|
|
StringSchema.ipRegex,
|
|
msg || "String must be a valid ip address",
|
|
);
|
|
}
|
|
}
|
|
|
|
class LiteralSchema<L extends string> extends BaseSchema<L> {
|
|
constructor(
|
|
public readonly literal: L,
|
|
msg?: string,
|
|
) {
|
|
super(msg);
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<L, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<string>(
|
|
input,
|
|
"string",
|
|
this.msg,
|
|
)
|
|
.andThen((str) =>
|
|
str === this.literal ? ok(str as L) : err(
|
|
createValidationError(
|
|
input,
|
|
{
|
|
kind: "typeMismatch",
|
|
expected: this.literal,
|
|
received: str,
|
|
msg: this.msg,
|
|
},
|
|
),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
class NumberSchema extends BaseSchema<number> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<number, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<number>(
|
|
input,
|
|
"number",
|
|
this.msg,
|
|
);
|
|
}
|
|
|
|
public gt(
|
|
num: number,
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (value <= num) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg || `Number must be greater than ${num}`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public gte(
|
|
num: number,
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (value < num) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg ||
|
|
`Number must be greater than or equal to ${num}`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public lt(
|
|
num: number,
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (value >= num) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg || `Number must be less than ${num}`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public lte(
|
|
num: number,
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (value > num) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg ||
|
|
`Number must be less than or equal to ${num}`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public int(
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (!Number.isInteger(value)) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg ||
|
|
`Number must be an integer`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public positive(
|
|
msg?: string,
|
|
): this {
|
|
return this.gt(0, msg || "Number must be positive");
|
|
}
|
|
|
|
public nonnegative(
|
|
msg?: string,
|
|
) {
|
|
return this.gte(0, msg || "Number must be nonnegative");
|
|
}
|
|
|
|
public negative(
|
|
msg?: string,
|
|
): this {
|
|
return this.lt(0, msg || "Number must be negative");
|
|
}
|
|
|
|
public nonpositive(
|
|
msg?: string,
|
|
) {
|
|
return this.lte(0, msg || "Number must be nonpositive");
|
|
}
|
|
|
|
public finite(
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (!Number.isFinite(value)) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg ||
|
|
`Number must be an integer`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public safe(
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (!Number.isSafeInteger(value)) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg ||
|
|
`Number must be an integer`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public multipleOf(
|
|
num: number,
|
|
msg?: string,
|
|
): this {
|
|
return this.addCheck((value) => {
|
|
if (value % num !== 0) {
|
|
return createValidationError(
|
|
value,
|
|
{
|
|
kind: "general",
|
|
msg: msg || `Number must be a multiple of ${num}`,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class BigintSchema extends BaseSchema<bigint> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<bigint, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<bigint>(
|
|
input,
|
|
"bigint",
|
|
this.msg,
|
|
);
|
|
}
|
|
}
|
|
|
|
class BooleanSchema extends BaseSchema<boolean> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<boolean, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<boolean>(
|
|
input,
|
|
"boolean",
|
|
this.msg,
|
|
);
|
|
}
|
|
}
|
|
|
|
class DateSchema extends BaseSchema<Date> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<Date, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<object>(
|
|
input,
|
|
"object",
|
|
this.msg,
|
|
)
|
|
.andThen((o) => {
|
|
if (o instanceof Date) return ok(o as Date);
|
|
|
|
const received = o?.constructor?.name ?? "unknown";
|
|
return err(createValidationError(
|
|
input,
|
|
{
|
|
kind: "typeMismatch",
|
|
expected: "Date instance",
|
|
received,
|
|
msg: this.msg,
|
|
},
|
|
));
|
|
});
|
|
}
|
|
}
|
|
|
|
class SymbolSchema extends BaseSchema<symbol> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<symbol, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<symbol>(
|
|
input,
|
|
"symbol",
|
|
this.msg,
|
|
);
|
|
}
|
|
}
|
|
|
|
class UndefinedSchema extends BaseSchema<undefined> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<undefined, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<undefined>(
|
|
input,
|
|
"undefined",
|
|
this.msg,
|
|
);
|
|
}
|
|
}
|
|
|
|
class NullSchema extends BaseSchema<null> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<null, SchemaValidationError> {
|
|
if (input === null) return ok(input);
|
|
|
|
const received = typeof input === "object"
|
|
? input?.constructor?.name ?? "unknown"
|
|
: typeof input;
|
|
|
|
return err(
|
|
createValidationError(
|
|
input,
|
|
{
|
|
kind: "typeMismatch",
|
|
expected: "null",
|
|
received,
|
|
msg: this.msg,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class VoidSchema extends BaseSchema<void> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<void, SchemaValidationError> {
|
|
if (input === undefined || input === null) return ok();
|
|
|
|
const received = typeof input === "object"
|
|
? input?.constructor?.name ?? "unknown"
|
|
: typeof input;
|
|
|
|
return err(createValidationError(
|
|
input,
|
|
{
|
|
kind: "typeMismatch",
|
|
expected: "void (undefined/null)",
|
|
received,
|
|
msg: this.msg,
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
class AnySchema extends BaseSchema<any> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<any, SchemaValidationError> {
|
|
return ok(input);
|
|
}
|
|
}
|
|
|
|
class UnknownSchema extends BaseSchema<unknown> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<unknown, SchemaValidationError> {
|
|
return ok(input);
|
|
}
|
|
}
|
|
|
|
class NeverSchema extends BaseSchema<never> {
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<never, SchemaValidationError> {
|
|
return err(
|
|
createValidationError(
|
|
input,
|
|
{
|
|
kind: "typeMismatch",
|
|
expected: "never",
|
|
received: typeof input,
|
|
msg: this.msg,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ObjectSchema<S extends Record<string, Schema<any>>>
|
|
extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> {
|
|
private strictMode: boolean = false;
|
|
private objectMsg?;
|
|
|
|
constructor(
|
|
public readonly shape: S,
|
|
msg?: {
|
|
mismatch?: string;
|
|
nullObject?: string;
|
|
unexpectedProperty?: string;
|
|
propertyValidation?: string;
|
|
missingProperty?: string;
|
|
} | string,
|
|
) {
|
|
let mismatchMsg: string | undefined;
|
|
let objectMsg;
|
|
|
|
if (typeof msg === "string") {
|
|
mismatchMsg = msg;
|
|
} else if (typeof msg === "object") {
|
|
objectMsg = msg;
|
|
}
|
|
|
|
super(mismatchMsg);
|
|
|
|
this.objectMsg = objectMsg;
|
|
}
|
|
|
|
// TODO: Simplify it a bit
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<
|
|
{ [K in keyof S]: InferSchemaType<S[K]> },
|
|
SchemaValidationError
|
|
> {
|
|
return BaseSchema.validatePrimitive<object>(
|
|
input,
|
|
"object",
|
|
this.msg || this.objectMsg?.mismatch,
|
|
)
|
|
.andThen((obj) => {
|
|
if (obj === null) {
|
|
return err(
|
|
createValidationError(input, {
|
|
kind: "typeMismatch",
|
|
expected: "Non-null object",
|
|
received: "null",
|
|
msg: this.msg || this.objectMsg?.nullObject,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const resultObj: Record<string, unknown> = {};
|
|
const expectedKeys = new Set(Object.keys(this.shape));
|
|
|
|
for (const key of Object.keys(obj)) {
|
|
const schema = this.shape[key];
|
|
if (schema === undefined) {
|
|
if (this.strictMode) {
|
|
const keys = new Set(Object.keys(obj)).difference(
|
|
new Set(Object.keys(this.shape)),
|
|
).keys().toArray();
|
|
|
|
return err(
|
|
createValidationError(
|
|
input,
|
|
{
|
|
kind: "unexpectedProperties",
|
|
keys,
|
|
msg: this.msg ||
|
|
this.objectMsg
|
|
?.unexpectedProperty,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const result = schema.parse((obj as any)[key]);
|
|
if (result.isErr()) {
|
|
return err(
|
|
createValidationError(
|
|
input,
|
|
{
|
|
kind: "propertyValidation",
|
|
property: key,
|
|
detail: result.error.detail,
|
|
msg: this.msg ||
|
|
this.objectMsg?.propertyValidation,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
expectedKeys.delete(key);
|
|
resultObj[key] = result.value;
|
|
}
|
|
|
|
const missingProperties = expectedKeys.keys().filter(
|
|
(key) => !BaseSchema.isNullishSchema(this.shape[key]),
|
|
).toArray();
|
|
|
|
if (missingProperties.length > 0) {
|
|
return err(
|
|
createValidationError(
|
|
input,
|
|
{
|
|
kind: "missingProperties",
|
|
keys: missingProperties,
|
|
msg: this.msg ||
|
|
this.objectMsg?.missingProperty,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return ok(
|
|
resultObj as { [K in keyof S]: InferSchemaType<S[K]> },
|
|
);
|
|
});
|
|
}
|
|
|
|
strict(): this {
|
|
this.strictMode = true;
|
|
return this;
|
|
}
|
|
}
|
|
|
|
type InferUnionSchemaType<U extends Schema<any>[]> = U[number] extends
|
|
Schema<infer T> ? T : never;
|
|
|
|
class UnionSchema<U extends Schema<any>[]>
|
|
extends BaseSchema<InferUnionSchemaType<U>> {
|
|
private unionMsg?;
|
|
|
|
constructor(
|
|
public readonly schemas: U,
|
|
msg?: {
|
|
mismatch?: string;
|
|
unionValidation?: string;
|
|
} | string,
|
|
) {
|
|
let mismatchMsg: string | undefined;
|
|
let unionMsg;
|
|
|
|
if (typeof msg === "string") {
|
|
mismatchMsg = msg;
|
|
} else if (typeof msg === "object") {
|
|
unionMsg = msg;
|
|
}
|
|
|
|
super(mismatchMsg);
|
|
this.unionMsg = unionMsg;
|
|
}
|
|
|
|
private static getTypeFromSchemaName(name: string): string {
|
|
switch (name) {
|
|
case "StringSchema":
|
|
case "LiteralSchema":
|
|
return "string";
|
|
case "NumberSchema":
|
|
return "number";
|
|
case "BigintSchema":
|
|
return "bigint";
|
|
case "BooleanSchema":
|
|
return "boolean";
|
|
case "UndefinedSchema":
|
|
return "undefined";
|
|
case "SymbolSchema":
|
|
return "symbol";
|
|
default:
|
|
return "object";
|
|
}
|
|
}
|
|
|
|
protected validateInput(
|
|
input: unknown,
|
|
): Result<InferUnionSchemaType<U>, SchemaValidationError> {
|
|
const errors: ValidationErrorDetail[] = [];
|
|
let allTypeMismatch = true;
|
|
|
|
for (const schema of this.schemas) {
|
|
const result = schema.parse(input);
|
|
if (result.isOk()) {
|
|
return ok(result.value as InferUnionSchemaType<U>);
|
|
}
|
|
allTypeMismatch = result.error.detail?.kind === "typeMismatch" &&
|
|
allTypeMismatch;
|
|
errors.push(result.error.detail);
|
|
}
|
|
|
|
if (allTypeMismatch) {
|
|
return err(createValidationError(input, {
|
|
kind: "typeMismatch",
|
|
expected: this.schemas.map((s) =>
|
|
UnionSchema.getTypeFromSchemaName(
|
|
s.constructor.name,
|
|
)
|
|
).join(" | "),
|
|
received: typeof input,
|
|
msg: this.msg || this.unionMsg?.mismatch,
|
|
}));
|
|
}
|
|
|
|
return err(
|
|
createValidationError(input, {
|
|
kind: "unionValidation",
|
|
msg: this.msg ||
|
|
this.unionMsg?.unionValidation ||
|
|
"Input did not match any union member",
|
|
details: errors,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ArraySchema<S extends Schema<any>>
|
|
extends BaseSchema<InferSchemaType<S>[]> {
|
|
private arrayMsg?;
|
|
|
|
constructor(
|
|
private readonly schema: S,
|
|
msg?: {
|
|
mismatch?: string;
|
|
element?: string;
|
|
} | string,
|
|
) {
|
|
let mismatchMsg: string | undefined;
|
|
let arrayMsg;
|
|
|
|
if (typeof msg === "string") {
|
|
mismatchMsg = msg;
|
|
} else if (typeof msg === "object") {
|
|
arrayMsg = msg;
|
|
}
|
|
|
|
super(mismatchMsg);
|
|
// TODO: abstract complex schemas in a separate type with thos messages
|
|
this.arrayMsg = arrayMsg;
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<InferSchemaType<S>[], SchemaValidationError> {
|
|
if (!Array.isArray(input)) {
|
|
return err(createValidationError(input, {
|
|
kind: "typeMismatch",
|
|
expected: "Array",
|
|
received: "Non-array",
|
|
msg: this.msg || this.arrayMsg?.mismatch,
|
|
}));
|
|
}
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
const result = this.schema.parse(input[i]);
|
|
|
|
if (result.isErr()) {
|
|
return err(
|
|
createValidationError(input, {
|
|
kind: "arrayElement",
|
|
index: i,
|
|
detail: result.error.detail,
|
|
msg: this.msg || this.arrayMsg?.element,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return ok(input as InferSchemaType<S>[]);
|
|
}
|
|
}
|
|
|
|
class OptionalSchema<S extends Schema<any>>
|
|
extends BaseSchema<InferSchemaType<S> | undefined> {
|
|
constructor(
|
|
public readonly schema: S,
|
|
msg?: string,
|
|
) {
|
|
super(msg);
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<InferSchemaType<S> | undefined, SchemaValidationError> {
|
|
if (input === undefined) {
|
|
return ok(input);
|
|
}
|
|
return this.schema.parse(input);
|
|
}
|
|
}
|
|
|
|
class NullableSchema<S extends Schema<any>>
|
|
extends BaseSchema<InferSchemaType<S> | null> {
|
|
constructor(
|
|
public readonly schema: S,
|
|
msg?: string,
|
|
) {
|
|
super(msg);
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<InferSchemaType<S> | null, SchemaValidationError> {
|
|
if (input === null) {
|
|
return ok(input);
|
|
}
|
|
return this.schema.parse(input);
|
|
}
|
|
}
|
|
|
|
class NullishSchema<S extends Schema<any>>
|
|
extends BaseSchema<InferSchemaType<S> | undefined | null> {
|
|
constructor(
|
|
public readonly schema: S,
|
|
msg?: string,
|
|
) {
|
|
super(msg);
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<InferSchemaType<S> | undefined | null, SchemaValidationError> {
|
|
if (input === undefined || input === null) {
|
|
return ok(input);
|
|
}
|
|
return this.schema.parse(input);
|
|
}
|
|
}
|
|
|
|
class ResultSchema<T extends Schema<any>, E extends Schema<any>>
|
|
extends BaseSchema<Result<InferSchemaType<T>, InferSchemaType<E>>> {
|
|
constructor(
|
|
private readonly okSchema: T,
|
|
private readonly errSchema: E,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<
|
|
Result<InferSchemaType<T>, InferSchemaType<E>>,
|
|
SchemaValidationError
|
|
> {
|
|
return BaseSchema.validatePrimitive<object>(input, "object").andThen(
|
|
(
|
|
obj,
|
|
): Result<
|
|
Result<InferSchemaType<T>, InferSchemaType<E>>,
|
|
SchemaValidationError
|
|
> => {
|
|
if ("tag" in obj) {
|
|
switch (obj.tag) {
|
|
case "ok": {
|
|
if ("value" in obj) {
|
|
return this.okSchema.parse(
|
|
obj.value,
|
|
).match(
|
|
(v) => ok(ok(v as InferSchemaType<T>)),
|
|
(e) =>
|
|
err(createValidationError(input, {
|
|
kind: "propertyValidation",
|
|
property: "value",
|
|
detail: e.detail,
|
|
})),
|
|
);
|
|
} else if (
|
|
BaseSchema.isNullishSchema(this.okSchema)
|
|
) {
|
|
return ok(
|
|
ok() as Result<
|
|
InferSchemaType<T>,
|
|
InferSchemaType<E>
|
|
>,
|
|
);
|
|
}
|
|
return err(createValidationError(input, {
|
|
kind: "missingProperties",
|
|
keys: ["value"],
|
|
msg: "If tag is set to 'ok', than result must contain a 'value' property",
|
|
}));
|
|
}
|
|
case "err": {
|
|
if (
|
|
"error" in obj
|
|
) {
|
|
return this.errSchema.parse(
|
|
obj.error,
|
|
).match(
|
|
(e) => ok(err(e as InferSchemaType<E>)),
|
|
(e) =>
|
|
err(createValidationError(input, {
|
|
kind: "propertyValidation",
|
|
property: "error",
|
|
detail: e.detail,
|
|
})),
|
|
);
|
|
} else if (
|
|
BaseSchema.isNullishSchema(this.errSchema)
|
|
) {
|
|
return ok(
|
|
err() as Result<
|
|
InferSchemaType<T>,
|
|
InferSchemaType<E>
|
|
>,
|
|
);
|
|
}
|
|
return err(createValidationError(input, {
|
|
kind: "missingProperties",
|
|
keys: ["error"],
|
|
msg: "If tag is set to 'err', than result must contain a 'error' property",
|
|
}));
|
|
}
|
|
default:
|
|
return err(createValidationError(input, {
|
|
kind: "propertyValidation",
|
|
property: "tag",
|
|
detail: {
|
|
kind: "typeMismatch",
|
|
expected: "'ok' or 'err'",
|
|
received: `'${obj.tag}'`,
|
|
},
|
|
}));
|
|
}
|
|
} else {
|
|
return err(createValidationError(input, {
|
|
kind: "missingProperties",
|
|
keys: ["tag"],
|
|
msg: "Result must contain a tag property",
|
|
}));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class OptionSchema<T extends Schema<any>>
|
|
extends BaseSchema<Option<InferSchemaType<T>>> {
|
|
constructor(
|
|
private readonly schema: T,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
protected override validateInput(
|
|
input: unknown,
|
|
): Result<Option<InferSchemaType<T>>, SchemaValidationError> {
|
|
return BaseSchema.validatePrimitive<object>(input, "object").andThen(
|
|
(
|
|
obj,
|
|
): Result<Option<InferSchemaType<T>>, SchemaValidationError> => {
|
|
if ("tag" in obj) {
|
|
switch (obj.tag) {
|
|
case "some": {
|
|
if ("value" in obj) {
|
|
return this.schema.parse(
|
|
obj.value,
|
|
).match(
|
|
(v) => ok(some(v as InferSchemaType<T>)),
|
|
(e) =>
|
|
err(createValidationError(input, {
|
|
kind: "propertyValidation",
|
|
property: "value",
|
|
detail: e.detail,
|
|
})),
|
|
);
|
|
} else if (
|
|
BaseSchema.isNullishSchema(this.schema)
|
|
) {
|
|
return ok(some() as Option<InferSchemaType<T>>);
|
|
}
|
|
return err(createValidationError(input, {
|
|
kind: "missingProperties",
|
|
keys: ["value"],
|
|
msg: "If tag is set to 'some', than option must contain a 'value' property",
|
|
}));
|
|
}
|
|
case "none": {
|
|
return ok(none);
|
|
}
|
|
default:
|
|
return err(createValidationError(input, {
|
|
kind: "propertyValidation",
|
|
property: "tag",
|
|
detail: {
|
|
kind: "typeMismatch",
|
|
expected: "'some' or 'none'",
|
|
received: `'${obj.tag}'`,
|
|
},
|
|
}));
|
|
}
|
|
} else {
|
|
return err(createValidationError(input, {
|
|
kind: "missingProperties",
|
|
keys: ["tag"],
|
|
msg: "Option must contain a tag property",
|
|
}));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */
|
|
|
|
export const z = {
|
|
string: (msg?: string) => new StringSchema(msg),
|
|
literal: <L extends string>(lit: L, msg?: string) =>
|
|
new LiteralSchema<L>(lit, msg),
|
|
number: (msg?: string) => new NumberSchema(msg),
|
|
bigint: (msg?: string) => new BigintSchema(msg),
|
|
boolean: (msg?: string) => new BooleanSchema(msg),
|
|
date: (msg?: string) => new DateSchema(msg),
|
|
symbol: (msg?: string) => new SymbolSchema(msg),
|
|
undefined: (msg?: string) => new UndefinedSchema(msg),
|
|
null: (msg?: string) => new NullSchema(msg),
|
|
void: (msg?: string) => new VoidSchema(msg),
|
|
any: (msg?: string) => new AnySchema(msg),
|
|
unknown: (msg?: string) => new UnknownSchema(msg),
|
|
never: (msg?: string) => new NeverSchema(msg),
|
|
obj: <S extends Record<string, Schema<any>>>(
|
|
schema: S,
|
|
msg?: string | {
|
|
mismatch?: string;
|
|
nullObject?: string;
|
|
unexpectedProperty?: string;
|
|
propertyValidation?: string;
|
|
missingProperty?: string;
|
|
},
|
|
) => new ObjectSchema<S>(schema, msg),
|
|
union: <U extends Schema<any>[]>(
|
|
schemas: U,
|
|
msg?: string | {
|
|
mismatch?: string;
|
|
unionValidation?: string;
|
|
},
|
|
) => new UnionSchema<U>(schemas, msg),
|
|
array: <S extends Schema<any>>(
|
|
schema: S,
|
|
msg?: string | { mismatch?: string; element?: string },
|
|
) => new ArraySchema<S>(schema, msg),
|
|
optional: <S extends Schema<any>>(
|
|
schema: S,
|
|
msg?: string,
|
|
) => new OptionalSchema<S>(schema, msg),
|
|
nullable: <S extends Schema<any>>(
|
|
schema: S,
|
|
msg?: string,
|
|
) => new NullableSchema<S>(schema, msg),
|
|
nullish: <S extends Schema<any>>(
|
|
schema: S,
|
|
msg?: string,
|
|
) => new NullishSchema<S>(schema, msg),
|
|
result: <T extends Schema<any>, E extends Schema<any>>(
|
|
okSchema: T,
|
|
errSchema: E,
|
|
) => new ResultSchema<T, E>(okSchema, errSchema),
|
|
option: <T extends Schema<any>>(
|
|
schema: T,
|
|
) => new OptionSchema<T>(schema),
|
|
};
|
|
|
|
export type InferSchemaType<S> = S extends Schema<infer T> ? T : never;
|