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 ───────────────────────────────────────────────────── export 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 }; export class SchemaValidationError extends Error { public readonly type = "SchemaValiationError"; constructor( public readonly input: unknown, public readonly detail: ValidationErrorDetail, ) { super( SchemaValidationError.getBestMsg(detail) || "Schema validation error", ); this.name = "SchemaValidationError"; } public format(): Record { 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>( (acc, key) => { acc[key] = msg; return acc; }, {}, ); } case "arrayElement": { const detailObj: Record = {}; 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 { parse(input: unknown): Result; } type ValidationCheck = (value: T) => SchemaValidationError | void; export abstract class BaseSchema implements Schema { protected checks: ValidationCheck[] = []; constructor(public readonly msg?: string) {} public parse(input: unknown): Result { return this.validateInput(input).andThen((value) => this.applyValidationChecks(value) ); } protected abstract validateInput( input: unknown, ): Result; protected static validatePrimitive( input: unknown, expectedType: PrimitiveTypeName, msg?: string, ): Result { 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): this { this.checks.push(check); return this; } protected applyValidationChecks( value: T, ): Result { for (const check of this.checks) { const error = check(value); if (error) { return err(error); } } return ok(value); } protected static isNullishSchema(schema: Schema): boolean { return schema.parse(null).isOk() || schema.parse(undefined).isOk(); } } export class StringSchema extends BaseSchema { 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 { return BaseSchema.validatePrimitive( 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 extends BaseSchema { constructor( public readonly literal: L, msg?: string, ) { super(msg); } protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( 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 { protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( 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 { protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( input, "bigint", this.msg, ); } } class BooleanSchema extends BaseSchema { protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( input, "boolean", this.msg, ); } } class DateSchema extends BaseSchema { protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( 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 { protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( input, "symbol", this.msg, ); } } class UndefinedSchema extends BaseSchema { protected override validateInput( input: unknown, ): Result { return BaseSchema.validatePrimitive( input, "undefined", this.msg, ); } } class NullSchema extends BaseSchema { protected override validateInput( input: unknown, ): Result { 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 { protected override validateInput( input: unknown, ): Result { 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 { protected override validateInput( input: unknown, ): Result { return ok(input); } } class UnknownSchema extends BaseSchema { protected override validateInput( input: unknown, ): Result { return ok(input); } } class NeverSchema extends BaseSchema { protected override validateInput( input: unknown, ): Result { return err( createValidationError( input, { kind: "typeMismatch", expected: "never", received: typeof input, msg: this.msg, }, ), ); } } class ObjectSchema>> extends BaseSchema<{ [K in keyof S]: InferSchemaType }> { 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 }, SchemaValidationError > { return BaseSchema.validatePrimitive( 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 = {}; 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 }, ); }); } strict(): this { this.strictMode = true; return this; } } type InferUnionSchemaType[]> = U[number] extends Schema ? T : never; class UnionSchema[]> extends BaseSchema> { 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, 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); } 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> extends BaseSchema[]> { 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[], 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[]); } } class OptionalSchema> extends BaseSchema | undefined> { constructor( public readonly schema: S, msg?: string, ) { super(msg); } protected override validateInput( input: unknown, ): Result | undefined, SchemaValidationError> { if (input === undefined) { return ok(input); } return this.schema.parse(input); } } class NullableSchema> extends BaseSchema | null> { constructor( public readonly schema: S, msg?: string, ) { super(msg); } protected override validateInput( input: unknown, ): Result | null, SchemaValidationError> { if (input === null) { return ok(input); } return this.schema.parse(input); } } class NullishSchema> extends BaseSchema | undefined | null> { constructor( public readonly schema: S, msg?: string, ) { super(msg); } protected override validateInput( input: unknown, ): Result | undefined | null, SchemaValidationError> { if (input === undefined || input === null) { return ok(input); } return this.schema.parse(input); } } export class ResultSchema, E extends Schema> extends BaseSchema, InferSchemaType>> { constructor( private readonly okSchema: T, private readonly errSchema: E, ) { super(); } protected override validateInput( input: unknown, ): Result< Result, InferSchemaType>, SchemaValidationError > { return BaseSchema.validatePrimitive(input, "object").andThen( ( obj, ): Result< Result, InferSchemaType>, 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)), (e) => err(createValidationError(input, { kind: "propertyValidation", property: "value", detail: e.detail, })), ); } else if ( BaseSchema.isNullishSchema(this.okSchema) ) { return ok( ok() as Result< InferSchemaType, InferSchemaType >, ); } 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) => err(createValidationError(input, { kind: "propertyValidation", property: "error", detail: e.detail, })), ); } else if ( BaseSchema.isNullishSchema(this.errSchema) ) { return ok( err() as Result< InferSchemaType, InferSchemaType >, ); } 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", })); } }, ); } } export class OptionSchema> extends BaseSchema>> { constructor( private readonly schema: T, ) { super(); } protected override validateInput( input: unknown, ): Result>, SchemaValidationError> { return BaseSchema.validatePrimitive(input, "object").andThen( ( obj, ): Result>, 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)), (e) => err(createValidationError(input, { kind: "propertyValidation", property: "value", detail: e.detail, })), ); } else if ( BaseSchema.isNullishSchema(this.schema) ) { return ok(some() as Option>); } 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: (lit: L, msg?: string) => new LiteralSchema(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: >>( schema: S, msg?: string | { mismatch?: string; nullObject?: string; unexpectedProperty?: string; propertyValidation?: string; missingProperty?: string; }, ) => new ObjectSchema(schema, msg), union: []>( schemas: U, msg?: string | { mismatch?: string; unionValidation?: string; }, ) => new UnionSchema(schemas, msg), array: >( schema: S, msg?: string | { mismatch?: string; element?: string }, ) => new ArraySchema(schema, msg), optional: >( schema: S, msg?: string, ) => new OptionalSchema(schema, msg), nullable: >( schema: S, msg?: string, ) => new NullableSchema(schema, msg), nullish: >( schema: S, msg?: string, ) => new NullishSchema(schema, msg), result: , E extends Schema>( okSchema: T, errSchema: E, ) => new ResultSchema(okSchema, errSchema), option: >( schema: T, ) => new OptionSchema(schema), }; export type InferSchemaType = S extends Schema ? T : never;