From 666818b83fc0de077806e1dd3d6b7d735be066ec Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Tue, 6 Jan 2026 08:18:32 +0100 Subject: [PATCH] Add copy method to preserve instance type with type mpdifier methods --- .changeset/kind-areas-fix.md | 5 + src/types/array.ts | 47 +++---- src/types/binary.ts | 4 + src/types/boolean.ts | 4 + src/types/date.ts | 61 +++++---- src/types/decimal128.ts | 4 + src/types/index.ts | 1 - src/types/literal.ts | 6 +- src/types/long.ts | 4 + src/types/mixed.ts | 4 + src/types/number.ts | 40 +++--- src/types/object.ts | 6 +- src/types/objectId.ts | 4 + src/types/pipe.ts | 9 +- src/types/record.ts | 6 +- src/types/string.ts | 88 ++++++------- src/types/tuple.ts | 6 +- src/types/type.ts | 220 +++++++++++++++++++++++++-------- src/types/union.ts | 12 +- tests/types/array.test.ts | 4 +- tests/types/binary.test.ts | 2 +- tests/types/boolean.test.ts | 2 +- tests/types/decimal128.test.ts | 2 +- tests/types/long.test.ts | 2 +- tests/types/objectid.test.ts | 2 +- tests/types/type.test.ts | 105 +++++++--------- 26 files changed, 397 insertions(+), 253 deletions(-) create mode 100644 .changeset/kind-areas-fix.md diff --git a/.changeset/kind-areas-fix.md b/.changeset/kind-areas-fix.md new file mode 100644 index 0000000..6dc97ee --- /dev/null +++ b/.changeset/kind-areas-fix.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": patch +--- + +Preserve concrete type with type modifier methods diff --git a/src/types/array.ts b/src/types/array.ts index bfe223f..763b2a4 100644 --- a/src/types/array.ts +++ b/src/types/array.ts @@ -39,6 +39,10 @@ export class MonarchArray extends MonarchType extends MonarchType { - if (input.length < length) { - throw new MonarchParseError(`array must have at least ${length} elements`); - } - return input; - }, + return this.parse((input) => { + if (input.length < length) { + throw new MonarchParseError(`array must have at least ${length} elements`); + } + return input; }); } @@ -63,13 +65,11 @@ export class MonarchArray extends MonarchType { - if (input.length > length) { - throw new MonarchParseError(`array must have at most ${length} elements`); - } - return input; - }, + return this.parse((input) => { + if (input.length > length) { + throw new MonarchParseError(`array must have at most ${length} elements`); + } + return input; }); } @@ -80,13 +80,11 @@ export class MonarchArray extends MonarchType { - if (input.length !== length) { - throw new MonarchParseError(`array must have exactly ${length} elements`); - } - return input; - }, + return this.parse((input) => { + if (input.length !== length) { + throw new MonarchParseError(`array must have exactly ${length} elements`); + } + return input; }); } @@ -96,6 +94,11 @@ export class MonarchArray extends MonarchType { + if (input.length === 0) { + throw new MonarchParseError("array must not be empty"); + } + return input; + }); } } diff --git a/src/types/binary.ts b/src/types/binary.ts index 05866b4..909023b 100644 --- a/src/types/binary.ts +++ b/src/types/binary.ts @@ -20,4 +20,8 @@ export class MonarchBinary extends MonarchType { throw new MonarchParseError(`expected 'Buffer' or 'Binary' received '${typeof input}'`); }); } + + protected copy() { + return new MonarchBinary(); + } } diff --git a/src/types/boolean.ts b/src/types/boolean.ts index 796a4b1..7946f23 100644 --- a/src/types/boolean.ts +++ b/src/types/boolean.ts @@ -18,4 +18,8 @@ export class MonarchBoolean extends MonarchType { throw new MonarchParseError(`expected 'boolean' received '${typeof input}'`); }); } + + protected copy() { + return new MonarchBoolean(); + } } diff --git a/src/types/date.ts b/src/types/date.ts index d1fc5ff..5727931 100644 --- a/src/types/date.ts +++ b/src/types/date.ts @@ -19,6 +19,10 @@ export class MonarchDate extends MonarchType { }); } + protected copy() { + return new MonarchDate(); + } + /** * Validates date is after a target date. * @@ -26,13 +30,11 @@ export class MonarchDate extends MonarchType { * @returns MonarchDate with after validation */ public after(targetDate: Date) { - return date().extend(this, { - parse: (input) => { - if (input <= targetDate) { - throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); - } - return input; - }, + return this.parse((input) => { + if (input <= targetDate) { + throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); + } + return input; }); } @@ -43,13 +45,11 @@ export class MonarchDate extends MonarchType { * @returns MonarchDate with before validation */ public before(targetDate: Date) { - return date().extend(this, { - parse: (input) => { - if (input >= targetDate) { - throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); - } - return input; - }, + return this.parse((input) => { + if (input >= targetDate) { + throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); + } + return input; }); } } @@ -66,10 +66,7 @@ export const createdAt = () => date().default(() => new Date()); * * @returns MonarchDate with update and default values */ -export const updatedAt = () => { - const base = date(); - return base.extend(base, { onUpdate: () => new Date() }).default(() => new Date()); -}; +export const updatedAt = () => createdAt().onUpdate(() => new Date()); /** * Date string type that accepts ISO date strings. @@ -91,6 +88,10 @@ export class MonarchDateString extends MonarchType { }); } + protected copy() { + return new MonarchDateString(); + } + /** * Validates date is after a target date. * @@ -98,13 +99,11 @@ export class MonarchDateString extends MonarchType { * @returns MonarchDateString with after validation */ public after(targetDate: Date) { - return dateString().extend(this, { - parse: (input) => { - if (input <= targetDate) { - throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); - } - return input; - }, + return this.parse((input: Date) => { + if (input <= targetDate) { + throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); + } + return input; }); } @@ -115,13 +114,11 @@ export class MonarchDateString extends MonarchType { * @returns MonarchDateString with before validation */ public before(targetDate: Date) { - return dateString().extend(this, { - parse: (input) => { - if (input >= targetDate) { - throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); - } - return input; - }, + return this.parse((input: Date) => { + if (input >= targetDate) { + throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); + } + return input; }); } } diff --git a/src/types/decimal128.ts b/src/types/decimal128.ts index b4fc62f..85ad156 100644 --- a/src/types/decimal128.ts +++ b/src/types/decimal128.ts @@ -20,4 +20,8 @@ export class MonarchDecimal128 extends MonarchType(...values: T[]) => * Type for literal fields. */ export class MonarchLiteral extends MonarchType { - constructor(values: T[]) { + constructor(private values: T[]) { super((input) => { const _values = new Set(values); if (_values.has(input)) return input; throw new MonarchParseError(`unknown value '${input}', literal may only specify known values`); }); } + + protected copy() { + return new MonarchLiteral(this.values); + } } diff --git a/src/types/long.ts b/src/types/long.ts index 13206cc..313bf1a 100644 --- a/src/types/long.ts +++ b/src/types/long.ts @@ -27,4 +27,8 @@ export class MonarchLong extends MonarchType { return input; }); } + + protected copy() { + return new MonarchMixed(); + } } diff --git a/src/types/number.ts b/src/types/number.ts index cfd3e43..7864133 100644 --- a/src/types/number.ts +++ b/src/types/number.ts @@ -19,6 +19,10 @@ export class MonarchNumber extends MonarchType { }); } + protected copy() { + return new MonarchNumber(); + } + /** * Validates minimum value. * @@ -26,13 +30,11 @@ export class MonarchNumber extends MonarchType { * @returns MonarchNumber with min validation */ public min(value: number) { - return number().extend(this, { - parse: (input) => { - if (input < value) { - throw new MonarchParseError(`number must be greater than or equal to ${value}`); - } - return input; - }, + return this.parse((input) => { + if (input < value) { + throw new MonarchParseError(`number must be greater than or equal to ${value}`); + } + return input; }); } @@ -43,13 +45,11 @@ export class MonarchNumber extends MonarchType { * @returns MonarchNumber with max validation */ public max(value: number) { - return number().extend(this, { - parse: (input) => { - if (input > value) { - throw new MonarchParseError(`number must be less than or equal to ${value}`); - } - return input; - }, + return this.parse((input) => { + if (input > value) { + throw new MonarchParseError(`number must be less than or equal to ${value}`); + } + return input; }); } @@ -59,13 +59,11 @@ export class MonarchNumber extends MonarchType { * @returns MonarchNumber with integer validation */ public integer() { - return number().extend(this, { - parse: (input) => { - if (!Number.isInteger(input)) { - throw new MonarchParseError("number must be an integer"); - } - return input; - }, + return this.parse((input) => { + if (!Number.isInteger(input)) { + throw new MonarchParseError("number must be an integer"); + } + return input; }); } } diff --git a/src/types/object.ts b/src/types/object.ts index ac99a3d..7b603ac 100644 --- a/src/types/object.ts +++ b/src/types/object.ts @@ -17,7 +17,7 @@ export class MonarchObject> extends Mon InferTypeObjectInput, InferTypeObjectOutput > { - constructor(types: T) { + constructor(private types: T) { super((input) => { if (typeof input === "object" && input !== null) { for (const key of Object.keys(input)) { @@ -42,4 +42,8 @@ export class MonarchObject> extends Mon throw new MonarchParseError(`expected 'object' received '${typeof input}'`); }); } + + protected copy() { + return new MonarchObject(this.types); + } } diff --git a/src/types/objectId.ts b/src/types/objectId.ts index d67b741..04de11a 100644 --- a/src/types/objectId.ts +++ b/src/types/objectId.ts @@ -19,4 +19,8 @@ export class MonarchObjectId extends MonarchType { throw new MonarchParseError(`expected valid ObjectId received '${typeof input}' ${input}`); }); } + + protected copy() { + return new MonarchObjectId(); + } } diff --git a/src/types/pipe.ts b/src/types/pipe.ts index ae6562a..ae1c2d0 100644 --- a/src/types/pipe.ts +++ b/src/types/pipe.ts @@ -20,7 +20,10 @@ export class MonarchPipe< TPipeIn extends AnyMonarchType, TPipeOut extends AnyMonarchType, any>, > extends MonarchType, InferTypeOutput> { - constructor(pipeIn: TPipeIn, pipeOut: TPipeOut) { + constructor( + private pipeIn: TPipeIn, + private pipeOut: TPipeOut, + ) { super( pipeParser< InferTypeInput, @@ -29,4 +32,8 @@ export class MonarchPipe< >(MonarchType.parser(pipeIn), MonarchType.parser(pipeOut)), ); } + + protected copy() { + return new MonarchPipe(this.pipeIn, this.pipeOut); + } } diff --git a/src/types/record.ts b/src/types/record.ts index 77abdc3..24c4d06 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -17,7 +17,7 @@ export class MonarchRecord extends MonarchType< Record>, Record> > { - constructor(type: T) { + constructor(private type: T) { super((input) => { if (typeof input === "object" && input !== null) { const parsed = {} as Record>; @@ -37,4 +37,8 @@ export class MonarchRecord extends MonarchType< throw new MonarchParseError(`expected 'object' received '${typeof input}'`); }); } + + protected copy() { + return new MonarchRecord(this.type); + } } diff --git a/src/types/string.ts b/src/types/string.ts index 7ae5f69..67b621c 100644 --- a/src/types/string.ts +++ b/src/types/string.ts @@ -19,15 +19,17 @@ export class MonarchString extends MonarchType { }); } + protected copy() { + return new MonarchString(); + } + /** * Trims whitespace from both ends of the string. * * @returns MonarchString with trim transformation */ public trim() { - return string().extend(this, { - parse: (input) => input.trim(), - }); + return this.parse((input) => input.trim()); } /** @@ -36,9 +38,7 @@ export class MonarchString extends MonarchType { * @returns MonarchString with lowercase transformation */ public lowercase() { - return string().extend(this, { - parse: (input) => input.toLowerCase(), - }); + return this.parse((input) => input.toLowerCase()); } /** @@ -47,9 +47,7 @@ export class MonarchString extends MonarchType { * @returns MonarchString with uppercase transformation */ public uppercase() { - return string().extend(this, { - parse: (input) => input.toUpperCase(), - }); + return this.parse((input) => input.toUpperCase()); } /** @@ -59,13 +57,11 @@ export class MonarchString extends MonarchType { * @returns MonarchString with length validation */ public minLength(length: number) { - return string().extend(this, { - parse: (input) => { - if (input.length < length) { - throw new MonarchParseError(`string must be at least ${length} characters long`); - } - return input; - }, + return this.parse((input) => { + if (input.length < length) { + throw new MonarchParseError(`string must be at least ${length} characters long`); + } + return input; }); } @@ -76,13 +72,11 @@ export class MonarchString extends MonarchType { * @returns MonarchString with length validation */ public maxLength(length: number) { - return string().extend(this, { - parse: (input) => { - if (input.length > length) { - throw new MonarchParseError(`string must be at most ${length} characters long`); - } - return input; - }, + return this.parse((input) => { + if (input.length > length) { + throw new MonarchParseError(`string must be at most ${length} characters long`); + } + return input; }); } @@ -93,13 +87,11 @@ export class MonarchString extends MonarchType { * @returns MonarchString with length validation */ public length(length: number) { - return string().extend(this, { - parse: (input) => { - if (input.length !== length) { - throw new MonarchParseError(`string must be exactly ${length} characters long`); - } - return input; - }, + return this.parse((input) => { + if (input.length !== length) { + throw new MonarchParseError(`string must be exactly ${length} characters long`); + } + return input; }); } @@ -110,13 +102,11 @@ export class MonarchString extends MonarchType { * @returns MonarchString with pattern validation */ public pattern(regex: RegExp) { - return string().extend(this, { - parse: (input) => { - if (!regex.test(input)) { - throw new MonarchParseError(`string must match pattern ${regex}`); - } - return input; - }, + return this.parse((input) => { + if (!regex.test(input)) { + throw new MonarchParseError(`string must match pattern ${regex}`); + } + return input; }); } @@ -126,13 +116,11 @@ export class MonarchString extends MonarchType { * @returns MonarchString with non-empty validation */ public nonempty() { - return string().extend(this, { - parse: (input) => { - if (input.length === 0) { - throw new MonarchParseError("string must not be empty"); - } - return input; - }, + return this.parse((input) => { + if (input.length === 0) { + throw new MonarchParseError("string must not be empty"); + } + return input; }); } @@ -143,13 +131,11 @@ export class MonarchString extends MonarchType { * @returns MonarchString with inclusion validation */ public includes(searchString: string) { - return string().extend(this, { - parse: (input) => { - if (!input.includes(searchString)) { - throw new MonarchParseError(`string must include "${searchString}"`); - } - return input; - }, + return this.parse((input) => { + if (!input.includes(searchString)) { + throw new MonarchParseError(`string must include "${searchString}"`); + } + return input; }); } } diff --git a/src/types/tuple.ts b/src/types/tuple.ts index 1f00d8b..dec335c 100644 --- a/src/types/tuple.ts +++ b/src/types/tuple.ts @@ -19,7 +19,7 @@ export class MonarchTuple exten InferTypeTupleInput, InferTypeTupleOutput > { - constructor(types: T) { + constructor(private types: T) { super((input) => { if (Array.isArray(input)) { if (input.length !== types.length) { @@ -42,4 +42,8 @@ export class MonarchTuple exten throw new MonarchParseError(`expected 'array' received '${typeof input}'`); }); } + + protected copy() { + return new MonarchTuple(this.types); + } } diff --git a/src/types/type.ts b/src/types/type.ts index f058752..bcfb6d4 100644 --- a/src/types/type.ts +++ b/src/types/type.ts @@ -1,4 +1,4 @@ -import { MonarchParseError } from "../errors"; +import { MonarchError, MonarchParseError } from "../errors"; import type { InferTypeInput, InferTypeOutput } from "./type-helpers"; /** @@ -20,25 +20,94 @@ export function pipeParser( return (input) => nextParser(prevParser(input)); } -/** - * Creates a MonarchType with custom parser. - * - * @param parser - Parser function - * @returns MonarchType instance - */ -export const type = (parser: Parser) => new MonarchType(parser); - export type AnyMonarchType = MonarchType; /** * Base class for all Monarch types. + * + * ## Extending MonarchType + * + * When creating a new type by extending MonarchType, you must implement the `copy()` method. + * The copy method should create a fresh instance in its default state with the same constructor + * parameters. The static `MonarchType.copy()` method will then preserve the current parser and + * optional updater from the original instance. + * + * @example Custom type with validation methods + * ```ts + * class EmailType extends MonarchType { + * constructor() { + * super((input) => { + * if (typeof input !== 'string') throw new MonarchParseError('Expected string'); + * return input; + * }); + * } + * + * protected copy() { + * return new EmailType(); + * } + * + * public domain(allowedDomain: string) { + * return this.parse((email) => { + * if (!email.endsWith(`@${allowedDomain}`)) { + * throw new MonarchParseError(`Email must be from ${allowedDomain}`); + * } + * return email; + * }); + * } + * } + * + * // Usage: email().domain('example.com') preserves EmailType + * ``` + * + * The `copy()` method enables sound copies - when you call methods like `preprocess()`, `parse()`, + * or `validate()`, they use `MonarchType.copy()` to create a new instance with the modified parser + * while preserving the type. This allows method chaining while maintaining type safety. */ -export class MonarchType { +export abstract class MonarchType { constructor( protected parser: Parser, - protected updater?: Parser, + private updater?: Parser, ) {} + /** + * Creates a fresh instance of this type in its default state. + * + * Subclasses must implement this method to create a new instance with the same + * constructor parameters. + * + * @returns A fresh instance of the same type + */ + protected abstract copy(): MonarchType; + + /** + * Creates a sound copy of a type instance. + * + * This static method ensures that: + * 1. The copy is the same instance type + * 2. The parser is preserved from the original instance + * 3. The updater is preserved from the original instance + * + * This enables method chaining while maintaining type safety - when you call methods + * like `parse()`, `preprocess()`, or `validate()`, they use this method to create + * a copy with the modified parser. + * + * @param type - The type instance to copy + * @returns A new instance with the same type, parser, and updater + * + * @internal This method is used internally by instance methods like `parse()`, `preprocess()` and `validate()` + */ + public static copy(type: T): T { + const copy = type.copy(); + if (copy.constructor !== type.constructor) { + throw new MonarchError( + `Expected copy() to return '${type.constructor.name}' but received '${copy.constructor.name}'`, + ); + } + copy.parser = type.parser; + copy.updater = type.updater; + return copy as T; + } + /** * Gets parser function from type. * @@ -55,7 +124,7 @@ export class MonarchType { * @param type - Monarch type * @returns Updater function or undefined */ - public static updater(type: T): Parser> | undefined { + public static updater(type: T): (() => InferTypeOutput) | undefined { return type.updater; } @@ -105,25 +174,16 @@ export class MonarchType { return defaulted(this, defaultInput as InferTypeInput | (() => InferTypeInput)); } - /** - * Auto-update field on every update operation. - * - * NOTE: onUpdate only works on top-level schema fields. It does not work on nested fields within objects or array elements. - * - * @param updateFn function that returns the new value for this field on update operations. - */ - public onUpdate(updateFn: () => TInput) { - return new MonarchType(this.parser, pipeParser(updateFn, this.parser)); - } - /** * Transform input. * * Transform is applied after previous validations and transforms have been applied. * @param fn function that returns a transformed input. */ - public transform(fn: (input: TOutput) => TTransformOutput) { - return new MonarchType(pipeParser(this.parser, fn), this.updater && pipeParser(this.updater, fn)); + public transform(fn: Parser): MonarchType { + const transform = new CustomType(pipeParser(this.parser, fn)); + if (this.updater) transform.updater = pipeParser(this.updater, fn); + return transform; } /** @@ -134,38 +194,82 @@ export class MonarchType { * @param message error message when validation fails. */ public validate(fn: (input: TOutput) => boolean, message: string) { - return new MonarchType( - pipeParser(this.parser, (input) => { - const valid = fn(input); - if (!valid) throw new MonarchParseError(message); - return input; - }), - this.updater, - ); + const copy = MonarchType.copy(this); + copy.parser = pipeParser(copy.parser, (input) => { + const valid = fn(input); + if (!valid) throw new MonarchParseError(message); + return input; + }); + return copy; } /** - * Extends the parser and updater of this type from that of the base type. + * Preprocess input before parsing. * - * Extends should be called on a new instance of the type as it always mutates the type. + * Preprocessing is applied before the current parser. + * @param fn function that preprocesses the input. + */ + public preprocess(fn: Parser) { + const copy = MonarchType.copy(this); + copy.parser = pipeParser(fn, copy.parser); + return copy; + } + + /** + * Parse output after current parsing. * - * @param base type to copy parser and updater from. - * @param options options to optionally modify the copied parser or replace the copied updater. - * @returns + * Parsing is applied after the current parser. + * @param fn function that parses the output. */ - public extend>( - base: T, - options: { - preprocess?: Parser; - parse?: Parser; - onUpdate?: Parser; - }, - ) { - let parser = options.preprocess ? pipeParser(options.preprocess, base.parser) : base.parser; - if (options.parse) parser = pipeParser(parser, options.parse); - this.parser = parser; - this.updater = options.onUpdate ? pipeParser(options.onUpdate, parser) : base.updater; - return this; + public parse(fn: Parser) { + const copy = MonarchType.copy(this); + copy.parser = pipeParser(copy.parser, fn); + if (copy.updater) { + copy.updater = pipeParser(copy.updater, fn); + } + return copy; + } + + /** + * Auto-update field on every update operation. + * + * NOTE: onUpdate only works on top-level schema fields. It does not work on nested fields within objects or array elements. + * + * @param updateFn function that returns the new value for this field on update operations. + */ + public onUpdate(updateFn: () => TInput) { + const copy = MonarchType.copy(this); + copy.updater = pipeParser(updateFn, this.parser); + return copy; + } +} + +/** + * Creates a MonarchType with a custom parser function. + * + * @param parser - Parser function that transforms input to output + * @returns MonarchType instance + * + * @example + * ```ts + * const positiveNumber = type((input: number) => { + * if (input <= 0) { + * throw new MonarchParseError('number must be positive'); + * } + * return input; + * }); + * ``` + */ +export const type = (parser: Parser): MonarchType => + new CustomType(parser); + +class CustomType extends MonarchType { + constructor(protected parser: Parser) { + super(parser); + } + + protected copy() { + return new CustomType(this.parser); } } @@ -194,6 +298,10 @@ export class MonarchNullable extends MonarchType< }, updater); } + protected copy() { + return new MonarchNullable(this.type); + } + protected isInstanceOf(target: new (...args: any[]) => any) { return this instanceof target || MonarchType.isInstanceOf(this.type, target); } @@ -224,6 +332,10 @@ export class MonarchOptional extends MonarchType< }, updater); } + protected copy() { + return new MonarchOptional(this.type); + } + protected isInstanceOf(target: new (...args: any[]) => any) { return this instanceof target || MonarchType.isInstanceOf(this.type, target); } @@ -250,7 +362,7 @@ export class MonarchDefaulted extends MonarchType< > { constructor( private type: T, - defaultInput: InferTypeInput | (() => InferTypeInput), + private defaultInput: InferTypeInput | (() => InferTypeInput), ) { const parser = MonarchType.parser(type); const updater = MonarchType.updater(type); @@ -264,6 +376,10 @@ export class MonarchDefaulted extends MonarchType< }, updater); } + protected copy() { + return new MonarchDefaulted(this.type, this.defaultInput); + } + protected isInstanceOf(target: new (...args: any[]) => any) { return this instanceof target || MonarchType.isInstanceOf(this.type, target); } diff --git a/src/types/union.ts b/src/types/union.ts index deffb90..c271330 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -22,7 +22,7 @@ export class MonarchUnion exten InferTypeUnionInput, InferTypeUnionOutput > { - constructor(variants: T) { + constructor(private variants: T) { super((input) => { for (const [index, type] of variants.entries()) { try { @@ -41,6 +41,10 @@ export class MonarchUnion exten throw new MonarchParseError(`expected one of union variants but received '${typeof input}'`); }); } + + protected copy() { + return new MonarchUnion(this.variants); + } } /** @@ -58,7 +62,7 @@ export class MonarchTaggedUnion> extend InferTypeTaggedUnionInput, InferTypeTaggedUnionOutput > { - constructor(variants: T) { + constructor(private variants: T) { super((input) => { if (typeof input === "object" && input !== null) { if (!("tag" in input)) { @@ -93,4 +97,8 @@ export class MonarchTaggedUnion> extend throw new MonarchParseError(`expected 'object' received '${typeof input}'`); }); } + + protected copy() { + return new MonarchTaggedUnion(this.variants); + } } diff --git a/tests/types/array.test.ts b/tests/types/array.test.ts index abee365..ef81702 100644 --- a/tests/types/array.test.ts +++ b/tests/types/array.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { Schema, createSchema } from "../../src"; import { array, number, string } from "../../src/types"; -describe("array()", () => { +describe("array", () => { test("validates array type", () => { const schema = createSchema("test", { items: array(number()), @@ -73,7 +73,7 @@ describe("array()", () => { const data = Schema.encode(schema, { items: ["a"] }); expect(data).toStrictEqual({ items: ["a"] }); - expect(() => Schema.encode(schema, { items: [] })).toThrowError("array must have at least 1 elements"); + expect(() => Schema.encode(schema, { items: [] })).toThrowError("array must not be empty"); }); test("array methods can be chained", () => { diff --git a/tests/types/binary.test.ts b/tests/types/binary.test.ts index 76876d0..b470ef3 100644 --- a/tests/types/binary.test.ts +++ b/tests/types/binary.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { binary } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("binary()", () => { +describe("binary", () => { test("validates Binary type", () => { const schema = createSchema("test", { data: binary(), diff --git a/tests/types/boolean.test.ts b/tests/types/boolean.test.ts index f61f99a..5e4ddce 100644 --- a/tests/types/boolean.test.ts +++ b/tests/types/boolean.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { Schema, createSchema } from "../../src"; import { boolean } from "../../src/types"; -describe("boolean()", () => { +describe("boolean", () => { test("validates boolean type", () => { const schema = createSchema("test", { isActive: boolean(), diff --git a/tests/types/decimal128.test.ts b/tests/types/decimal128.test.ts index 70d50f0..9bc91f5 100644 --- a/tests/types/decimal128.test.ts +++ b/tests/types/decimal128.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { decimal128 } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("decimal128()", () => { +describe("decimal128", () => { test("validates Decimal128 type", () => { const schema = createSchema("test", { value: decimal128(), diff --git a/tests/types/long.test.ts b/tests/types/long.test.ts index da2101a..8278126 100644 --- a/tests/types/long.test.ts +++ b/tests/types/long.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { long } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("long()", () => { +describe("long", () => { test("validates Long type", () => { const schema = createSchema("test", { value: long(), diff --git a/tests/types/objectid.test.ts b/tests/types/objectid.test.ts index d44cd94..5526cb1 100644 --- a/tests/types/objectid.test.ts +++ b/tests/types/objectid.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { objectId } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("objectId()", () => { +describe("objectId", () => { test("validates ObjectId type", () => { const schema = createSchema("test", { id: objectId(), diff --git a/tests/types/type.test.ts b/tests/types/type.test.ts index 2169d9f..616ef36 100644 --- a/tests/types/type.test.ts +++ b/tests/types/type.test.ts @@ -3,10 +3,10 @@ import { Schema, createSchema } from "../../src"; import { MonarchParseError } from "../../src/errors"; import { pipe, type } from "../../src/types"; -const simpleString = () => type((input) => input); -const simpleNumber = () => type((input) => input); +const simpleString = () => type((input: string) => input); +const simpleNumber = () => type((input: number) => input); -describe("type()", () => { +describe("type", () => { it("validates and transforms input", () => { const schema = createSchema("users", { age: simpleNumber() @@ -190,7 +190,7 @@ describe("type()", () => { }); }); - describe("extend", () => { + describe("preprocess and parse", () => { test("preprocess runs before base parser", () => { const executionOrder: string[] = []; @@ -200,12 +200,10 @@ describe("type()", () => { return input; }); - // Extend with preprocess - const extendedType = type((input) => input).extend(baseType, { - preprocess: (input) => { - executionOrder.push("preprocess"); - return input; - }, + // Add preprocess + const extendedType = baseType.preprocess((input) => { + executionOrder.push("preprocess"); + return input; }); const schema = createSchema("test", { value: extendedType }); @@ -223,12 +221,10 @@ describe("type()", () => { return input; }); - // Extend with parse - const extendedType = type((input) => input).extend(baseType, { - parse: (input) => { - executionOrder.push("parse"); - return input; - }, + // Add parse + const extendedType = baseType.parse((input) => { + executionOrder.push("parse"); + return input; }); const schema = createSchema("test", { value: extendedType }); @@ -246,17 +242,16 @@ describe("type()", () => { return input; }); - // Extend with both preprocess and parse - const extendedType = type((input) => input).extend(baseType, { - preprocess: (input) => { + // Add both preprocess and parse + const extendedType = baseType + .preprocess((input) => { executionOrder.push("preprocess"); return input; - }, - parse: (input) => { + }) + .parse((input) => { executionOrder.push("parse"); return input; - }, - }); + }); const schema = createSchema("test", { value: extendedType }); Schema.encode(schema, { value: "test" }); @@ -272,9 +267,7 @@ describe("type()", () => { return input; }); - const extendedType = type((input) => input).extend(baseType, { - preprocess: () => "PREPROCESSED", - }); + const extendedType = baseType.preprocess(() => "PREPROCESSED"); const schema = createSchema("test", { value: extendedType }); const data = Schema.encode(schema, { value: "anything" }); @@ -285,9 +278,7 @@ describe("type()", () => { test("parse can transform output after base parser", () => { const baseType = type((input) => input.toUpperCase()); - const extendedType = type((input) => input).extend(baseType, { - parse: (input) => input + "-PARSED", - }); + const extendedType = baseType.parse((input) => input + "-PARSED"); const schema = createSchema("test", { value: extendedType }); const data = Schema.encode(schema, { value: "hello" }); @@ -299,16 +290,15 @@ describe("type()", () => { // Base type that converts string to uppercase const baseType = type((input) => input.toUpperCase()); - const extendedType = type((input) => input).extend(baseType, { - preprocess: (input) => { + const extendedType = baseType + .preprocess((input) => { // Trim in preprocess return input.trim(); - }, - parse: (input) => { + }) + .parse((input) => { // Add prefix and suffix in parse return `[${input}]`; - }, - }); + }); const schema = createSchema("test", { value: extendedType }); const data = Schema.encode(schema, { value: " hello " }); @@ -319,15 +309,14 @@ describe("type()", () => { test("real-world example: string validation with preprocess and parse", () => { const baseType = type((input) => input); - const trimmedAndValidatedString = type((input) => input).extend(baseType, { - preprocess: (input) => input.trim(), - parse: (input) => { + const trimmedAndValidatedString = baseType + .preprocess((input) => input.trim()) + .parse((input) => { if (input.length === 0) { throw new MonarchParseError("string must not be empty after trimming"); } return input; - }, - }); + }); const schema = createSchema("test", { value: trimmedAndValidatedString }); @@ -342,13 +331,11 @@ describe("type()", () => { test("real-world example: number with range validation using parse", () => { const baseType = type((input) => input); - const percentageNumber = type((input) => input).extend(baseType, { - parse: (input) => { - if (input < 0 || input > 100) { - throw new MonarchParseError("number must be between 0 and 100"); - } - return input; - }, + const percentageNumber = baseType.parse((input) => { + if (input < 0 || input > 100) { + throw new MonarchParseError("number must be between 0 and 100"); + } + return input; }); const schema = createSchema("test", { value: percentageNumber }); @@ -360,7 +347,7 @@ describe("type()", () => { expect(() => Schema.encode(schema, { value: -10 })).toThrowError("number must be between 0 and 100"); }); - test("multiple extends chain correctly", () => { + test("multiple methods chain correctly", () => { const executionOrder: string[] = []; const baseType = type((input) => { @@ -368,27 +355,25 @@ describe("type()", () => { return input; }); - const firstExtend = type((input) => input).extend(baseType, { - preprocess: (input) => { + const firstExtend = baseType + .preprocess((input) => { executionOrder.push("first-preprocess"); return input; - }, - parse: (input) => { + }) + .parse((input) => { executionOrder.push("first-parse"); return input; - }, - }); + }); - const secondExtend = type((input) => input).extend(firstExtend, { - preprocess: (input) => { + const secondExtend = firstExtend + .preprocess((input) => { executionOrder.push("second-preprocess"); return input; - }, - parse: (input) => { + }) + .parse((input) => { executionOrder.push("second-parse"); return input; - }, - }); + }); const schema = createSchema("test", { value: secondExtend }); Schema.encode(schema, { value: "test" });