From 3dd62e7696bfa66afc1d5b9db381878ce12ce472 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Thu, 5 Feb 2026 22:24:12 -0300 Subject: [PATCH 1/2] feat(schema): implement Phase 2 StringSchema and NumberSchema StringSchema: type validation, min/max/length, regex, startsWith/endsWith/includes, uppercase/lowercase validation, trim/toLowerCase/toUpperCase/normalize transforms, per-rule custom error messages, JSON Schema output. NumberSchema: type validation (rejects NaN), gte/gt/lte/lt bounds, int, positive/ negative/nonnegative/nonpositive, multipleOf/step, finite, per-rule custom error messages, JSON Schema output with integer type support. 18 TDD cycles, 43 tests passing. Co-Authored-By: Claude Opus 4.6 --- packages/schema/src/index.ts | 4 + .../src/schemas/__tests__/number.test.ts | 103 ++++++++++ .../src/schemas/__tests__/string.test.ts | 109 ++++++++++ packages/schema/src/schemas/number.ts | 179 +++++++++++++++++ packages/schema/src/schemas/string.ts | 188 ++++++++++++++++++ 5 files changed, 583 insertions(+) create mode 100644 packages/schema/src/schemas/__tests__/number.test.ts create mode 100644 packages/schema/src/schemas/__tests__/string.test.ts create mode 100644 packages/schema/src/schemas/number.ts create mode 100644 packages/schema/src/schemas/string.ts diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 8387d5b07..f0edf009c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -15,5 +15,9 @@ export { SchemaRegistry } from './core/registry'; export { RefTracker, toJSONSchema } from './introspection/json-schema'; export type { JSONSchemaObject } from './introspection/json-schema'; +// Schemas +export { StringSchema } from './schemas/string'; +export { NumberSchema } from './schemas/number'; + // Type inference utilities export type { Infer, Output, Input } from './utils/type-inference'; diff --git a/packages/schema/src/schemas/__tests__/number.test.ts b/packages/schema/src/schemas/__tests__/number.test.ts new file mode 100644 index 000000000..722bbaa37 --- /dev/null +++ b/packages/schema/src/schemas/__tests__/number.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { NumberSchema } from '../number'; +import { ParseError } from '../../core/errors'; + +describe('NumberSchema', () => { + it('accepts a valid number and rejects non-number including NaN', () => { + const schema = new NumberSchema(); + expect(schema.parse(42)).toBe(42); + expect(schema.parse(0)).toBe(0); + expect(schema.parse(-3.14)).toBe(-3.14); + + for (const value of ['hello', true, null, undefined, {}, [], NaN]) { + const result = schema.safeParse(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(ParseError); + } + } + }); + + it('.gte()/.min() inclusive minimum and .gt() exclusive minimum', () => { + const gte = new NumberSchema().gte(5); + expect(gte.parse(5)).toBe(5); + expect(gte.parse(10)).toBe(10); + expect(() => gte.parse(4)).toThrow(ParseError); + + const min = new NumberSchema().min(5); + expect(min.parse(5)).toBe(5); + expect(() => min.parse(4)).toThrow(ParseError); + + const gt = new NumberSchema().gt(5); + expect(gt.parse(6)).toBe(6); + expect(() => gt.parse(5)).toThrow(ParseError); + }); + + it('.lte()/.max() inclusive maximum and .lt() exclusive maximum', () => { + const lte = new NumberSchema().lte(10); + expect(lte.parse(10)).toBe(10); + expect(lte.parse(5)).toBe(5); + expect(() => lte.parse(11)).toThrow(ParseError); + + const max = new NumberSchema().max(10); + expect(max.parse(10)).toBe(10); + expect(() => max.parse(11)).toThrow(ParseError); + + const lt = new NumberSchema().lt(10); + expect(lt.parse(9)).toBe(9); + expect(() => lt.parse(10)).toThrow(ParseError); + }); + + it('.int() rejects floats, .positive/.negative/.nonnegative/.nonpositive validate sign', () => { + const int = new NumberSchema().int(); + expect(int.parse(5)).toBe(5); + expect(() => int.parse(5.5)).toThrow(ParseError); + + expect(new NumberSchema().positive().parse(1)).toBe(1); + expect(() => new NumberSchema().positive().parse(0)).toThrow(ParseError); + expect(() => new NumberSchema().positive().parse(-1)).toThrow(ParseError); + + expect(new NumberSchema().negative().parse(-1)).toBe(-1); + expect(() => new NumberSchema().negative().parse(0)).toThrow(ParseError); + + expect(new NumberSchema().nonnegative().parse(0)).toBe(0); + expect(new NumberSchema().nonnegative().parse(1)).toBe(1); + expect(() => new NumberSchema().nonnegative().parse(-1)).toThrow(ParseError); + + expect(new NumberSchema().nonpositive().parse(0)).toBe(0); + expect(new NumberSchema().nonpositive().parse(-1)).toBe(-1); + expect(() => new NumberSchema().nonpositive().parse(1)).toThrow(ParseError); + }); + + it('.multipleOf()/.step() and .finite()', () => { + const mult = new NumberSchema().multipleOf(3); + expect(mult.parse(9)).toBe(9); + expect(() => mult.parse(10)).toThrow(ParseError); + + const step = new NumberSchema().step(5); + expect(step.parse(15)).toBe(15); + expect(() => step.parse(7)).toThrow(ParseError); + + const fin = new NumberSchema().finite(); + expect(fin.parse(42)).toBe(42); + expect(() => fin.parse(Infinity)).toThrow(ParseError); + expect(() => fin.parse(-Infinity)).toThrow(ParseError); + }); + + it('supports custom error messages and .toJSONSchema()', () => { + const schema = new NumberSchema().gte(1, 'Must be at least 1').lte(100, 'Must be at most 100'); + const result = schema.safeParse(0); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]!.message).toBe('Must be at least 1'); + } + + const jsonSchema = new NumberSchema().gte(0).lt(100).int().multipleOf(5).toJSONSchema(); + expect(jsonSchema).toEqual({ + type: 'integer', + minimum: 0, + exclusiveMaximum: 100, + multipleOf: 5, + }); + }); +}); diff --git a/packages/schema/src/schemas/__tests__/string.test.ts b/packages/schema/src/schemas/__tests__/string.test.ts new file mode 100644 index 000000000..576c39327 --- /dev/null +++ b/packages/schema/src/schemas/__tests__/string.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { StringSchema } from '../string'; +import { ParseError } from '../../core/errors'; + +describe('StringSchema', () => { + it('accepts a valid string', () => { + const schema = new StringSchema(); + expect(schema.parse('hello')).toBe('hello'); + }); + + it('rejects non-string values', () => { + const schema = new StringSchema(); + for (const value of [42, true, null, undefined, {}, []]) { + const result = schema.safeParse(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(ParseError); + } + } + }); + + it('.min(n) accepts at boundary and rejects below', () => { + const schema = new StringSchema().min(3); + expect(schema.parse('abc')).toBe('abc'); + expect(schema.parse('abcd')).toBe('abcd'); + expect(() => schema.parse('ab')).toThrow(ParseError); + }); + + it('.max(n) accepts at boundary and rejects above', () => { + const schema = new StringSchema().max(5); + expect(schema.parse('abcde')).toBe('abcde'); + expect(schema.parse('abc')).toBe('abc'); + expect(() => schema.parse('abcdef')).toThrow(ParseError); + }); + + it('.length(n) accepts exact length and rejects different', () => { + const schema = new StringSchema().length(4); + expect(schema.parse('abcd')).toBe('abcd'); + expect(() => schema.parse('abc')).toThrow(ParseError); + expect(() => schema.parse('abcde')).toThrow(ParseError); + }); + + it('.regex(pattern) accepts matching and rejects non-matching', () => { + const schema = new StringSchema().regex(/^[a-z]+$/); + expect(schema.parse('hello')).toBe('hello'); + expect(() => schema.parse('Hello123')).toThrow(ParseError); + }); + + it('.startsWith(), .endsWith(), .includes() validate substrings', () => { + const schema = new StringSchema().startsWith('hello'); + expect(schema.parse('hello world')).toBe('hello world'); + expect(() => schema.parse('world hello')).toThrow(ParseError); + + const schema2 = new StringSchema().endsWith('world'); + expect(schema2.parse('hello world')).toBe('hello world'); + expect(() => schema2.parse('world hello')).toThrow(ParseError); + + const schema3 = new StringSchema().includes('mid'); + expect(schema3.parse('a mid b')).toBe('a mid b'); + expect(() => schema3.parse('no match')).toThrow(ParseError); + }); + + it('.uppercase() validates all uppercase and .lowercase() validates all lowercase', () => { + const upper = new StringSchema().uppercase(); + expect(upper.parse('HELLO')).toBe('HELLO'); + expect(() => upper.parse('Hello')).toThrow(ParseError); + + const lower = new StringSchema().lowercase(); + expect(lower.parse('hello')).toBe('hello'); + expect(() => lower.parse('Hello')).toThrow(ParseError); + }); + + it('.trim() trims whitespace before validation', () => { + const schema = new StringSchema().trim().min(3); + expect(schema.parse(' hello ')).toBe('hello'); + expect(() => schema.parse(' ab ')).toThrow(ParseError); + }); + + it('.toLowerCase(), .toUpperCase(), .normalize() transform the value', () => { + expect(new StringSchema().toLowerCase().parse('HELLO')).toBe('hello'); + expect(new StringSchema().toUpperCase().parse('hello')).toBe('HELLO'); + expect(new StringSchema().normalize().parse('\u00e9')).toBe('\u00e9'); + expect(new StringSchema().normalize().parse('e\u0301')).toBe('\u00e9'); + }); + + it('supports per-rule custom error messages', () => { + const schema = new StringSchema().min(5, 'Too short').max(10, 'Too long'); + const minResult = schema.safeParse('ab'); + expect(minResult.success).toBe(false); + if (!minResult.success) { + expect(minResult.error.issues[0]!.message).toBe('Too short'); + } + const maxResult = schema.safeParse('a]'.repeat(6)); + expect(maxResult.success).toBe(false); + if (!maxResult.success) { + expect(maxResult.error.issues[0]!.message).toBe('Too long'); + } + }); + + it('.toJSONSchema() returns type with minLength, maxLength, pattern', () => { + const schema = new StringSchema().min(1).max(100).regex(/^[a-z]+$/); + expect(schema.toJSONSchema()).toEqual({ + type: 'string', + minLength: 1, + maxLength: 100, + pattern: '^[a-z]+$', + }); + }); +}); diff --git a/packages/schema/src/schemas/number.ts b/packages/schema/src/schemas/number.ts new file mode 100644 index 000000000..cdda8604c --- /dev/null +++ b/packages/schema/src/schemas/number.ts @@ -0,0 +1,179 @@ +import { Schema } from '../core/schema'; +import { ParseContext } from '../core/parse-context'; +import { ErrorCode } from '../core/errors'; +import { SchemaType } from '../core/types'; +import type { RefTracker } from '../introspection/json-schema'; +import type { JSONSchemaObject } from '../introspection/json-schema'; + +export class NumberSchema extends Schema { + private _gte: number | undefined; + private _gteMessage: string | undefined; + private _gt: number | undefined; + private _lte: number | undefined; + private _lteMessage: string | undefined; + private _lt: number | undefined; + private _int: boolean = false; + private _positive: boolean = false; + private _negative: boolean = false; + private _nonnegative: boolean = false; + private _nonpositive: boolean = false; + private _multipleOf: number | undefined; + private _finite: boolean = false; + + _parse(value: unknown, ctx: ParseContext): number { + if (typeof value !== 'number' || Number.isNaN(value)) { + ctx.addIssue({ code: ErrorCode.InvalidType, message: 'Expected number, received ' + typeof value }); + return value as number; + } + if (this._gte !== undefined && value < this._gte) { + ctx.addIssue({ code: ErrorCode.TooSmall, message: this._gteMessage ?? `Number must be greater than or equal to ${this._gte}` }); + } + if (this._gt !== undefined && value <= this._gt) { + ctx.addIssue({ code: ErrorCode.TooSmall, message: `Number must be greater than ${this._gt}` }); + } + if (this._lte !== undefined && value > this._lte) { + ctx.addIssue({ code: ErrorCode.TooBig, message: this._lteMessage ?? `Number must be less than or equal to ${this._lte}` }); + } + if (this._lt !== undefined && value >= this._lt) { + ctx.addIssue({ code: ErrorCode.TooBig, message: `Number must be less than ${this._lt}` }); + } + if (this._int && !Number.isInteger(value)) { + ctx.addIssue({ code: ErrorCode.InvalidType, message: 'Expected integer, received float' }); + } + if (this._positive && value <= 0) { + ctx.addIssue({ code: ErrorCode.TooSmall, message: 'Number must be positive' }); + } + if (this._negative && value >= 0) { + ctx.addIssue({ code: ErrorCode.TooBig, message: 'Number must be negative' }); + } + if (this._nonnegative && value < 0) { + ctx.addIssue({ code: ErrorCode.TooSmall, message: 'Number must be nonnegative' }); + } + if (this._nonpositive && value > 0) { + ctx.addIssue({ code: ErrorCode.TooBig, message: 'Number must be nonpositive' }); + } + if (this._multipleOf !== undefined && value % this._multipleOf !== 0) { + ctx.addIssue({ code: ErrorCode.NotMultipleOf, message: `Number must be a multiple of ${this._multipleOf}` }); + } + if (this._finite && !Number.isFinite(value)) { + ctx.addIssue({ code: ErrorCode.NotFinite, message: 'Number must be finite' }); + } + return value; + } + + gte(n: number, message?: string): NumberSchema { + const clone = this._clone(); + clone._gte = n; + clone._gteMessage = message; + return clone; + } + + min(n: number, message?: string): NumberSchema { + return this.gte(n, message); + } + + gt(n: number): NumberSchema { + const clone = this._clone(); + clone._gt = n; + return clone; + } + + lte(n: number, message?: string): NumberSchema { + const clone = this._clone(); + clone._lte = n; + clone._lteMessage = message; + return clone; + } + + max(n: number, message?: string): NumberSchema { + return this.lte(n, message); + } + + lt(n: number): NumberSchema { + const clone = this._clone(); + clone._lt = n; + return clone; + } + + int(): NumberSchema { + const clone = this._clone(); + clone._int = true; + return clone; + } + + positive(): NumberSchema { + const clone = this._clone(); + clone._positive = true; + return clone; + } + + negative(): NumberSchema { + const clone = this._clone(); + clone._negative = true; + return clone; + } + + nonnegative(): NumberSchema { + const clone = this._clone(); + clone._nonnegative = true; + return clone; + } + + nonpositive(): NumberSchema { + const clone = this._clone(); + clone._nonpositive = true; + return clone; + } + + multipleOf(n: number): NumberSchema { + const clone = this._clone(); + clone._multipleOf = n; + return clone; + } + + step(n: number): NumberSchema { + return this.multipleOf(n); + } + + finite(): NumberSchema { + const clone = this._clone(); + clone._finite = true; + return clone; + } + + _schemaType(): SchemaType { + return SchemaType.Number; + } + + _toJSONSchema(_tracker: RefTracker): JSONSchemaObject { + const schema: JSONSchemaObject = { type: this._int ? 'integer' : 'number' }; + if (this._gte !== undefined) schema.minimum = this._gte; + if (this._gt !== undefined) schema.exclusiveMinimum = this._gt; + if (this._lte !== undefined) schema.maximum = this._lte; + if (this._lt !== undefined) schema.exclusiveMaximum = this._lt; + if (this._multipleOf !== undefined) schema.multipleOf = this._multipleOf; + return schema; + } + + _clone(): NumberSchema { + const clone = new NumberSchema(); + clone._id = this._id; + clone._description = this._description; + clone._meta = this._meta ? { ...this._meta } : undefined; + clone._examples = [...this._examples]; + clone._gte = this._gte; + clone._gteMessage = this._gteMessage; + clone._gt = this._gt; + clone._lte = this._lte; + clone._lteMessage = this._lteMessage; + clone._lt = this._lt; + clone._int = this._int; + clone._positive = this._positive; + clone._negative = this._negative; + clone._nonnegative = this._nonnegative; + clone._nonpositive = this._nonpositive; + clone._multipleOf = this._multipleOf; + clone._finite = this._finite; + return clone; + } +} diff --git a/packages/schema/src/schemas/string.ts b/packages/schema/src/schemas/string.ts new file mode 100644 index 000000000..1092b9980 --- /dev/null +++ b/packages/schema/src/schemas/string.ts @@ -0,0 +1,188 @@ +import { Schema } from '../core/schema'; +import { ParseContext } from '../core/parse-context'; +import { ErrorCode } from '../core/errors'; +import { SchemaType } from '../core/types'; +import type { RefTracker } from '../introspection/json-schema'; +import type { JSONSchemaObject } from '../introspection/json-schema'; + +export class StringSchema extends Schema { + private _min: number | undefined; + private _minMessage: string | undefined; + private _max: number | undefined; + private _maxMessage: string | undefined; + private _length: number | undefined; + private _regex: RegExp | undefined; + private _startsWith: string | undefined; + private _endsWith: string | undefined; + private _includes: string | undefined; + private _uppercase: boolean = false; + private _lowercase: boolean = false; + private _trim: boolean = false; + private _toLowerCase: boolean = false; + private _toUpperCase: boolean = false; + private _normalize: boolean = false; + + _parse(value: unknown, ctx: ParseContext): string { + if (typeof value !== 'string') { + ctx.addIssue({ code: ErrorCode.InvalidType, message: 'Expected string, received ' + typeof value }); + return value as string; + } + let v = value; + if (this._trim) { + v = v.trim(); + } + if (this._toLowerCase) { + v = v.toLowerCase(); + } + if (this._toUpperCase) { + v = v.toUpperCase(); + } + if (this._normalize) { + v = v.normalize(); + } + if (this._min !== undefined && v.length < this._min) { + ctx.addIssue({ code: ErrorCode.TooSmall, message: this._minMessage ?? `String must contain at least ${this._min} character(s)` }); + } + if (this._max !== undefined && v.length > this._max) { + ctx.addIssue({ code: ErrorCode.TooBig, message: this._maxMessage ?? `String must contain at most ${this._max} character(s)` }); + } + if (this._length !== undefined && v.length !== this._length) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: `String must be exactly ${this._length} character(s)` }); + } + if (this._regex !== undefined && !this._regex.test(v)) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid` }); + } + if (this._startsWith !== undefined && !v.startsWith(this._startsWith)) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid input: must start with "${this._startsWith}"` }); + } + if (this._endsWith !== undefined && !v.endsWith(this._endsWith)) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid input: must end with "${this._endsWith}"` }); + } + if (this._includes !== undefined && !v.includes(this._includes)) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid input: must include "${this._includes}"` }); + } + if (this._uppercase && v !== v.toUpperCase()) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: 'Expected string to be uppercase' }); + } + if (this._lowercase && v !== v.toLowerCase()) { + ctx.addIssue({ code: ErrorCode.InvalidString, message: 'Expected string to be lowercase' }); + } + return v; + } + + min(n: number, message?: string): StringSchema { + const clone = this._clone(); + clone._min = n; + clone._minMessage = message; + return clone; + } + + max(n: number, message?: string): StringSchema { + const clone = this._clone(); + clone._max = n; + clone._maxMessage = message; + return clone; + } + + length(n: number): StringSchema { + const clone = this._clone(); + clone._length = n; + return clone; + } + + regex(pattern: RegExp): StringSchema { + const clone = this._clone(); + clone._regex = pattern; + return clone; + } + + startsWith(prefix: string): StringSchema { + const clone = this._clone(); + clone._startsWith = prefix; + return clone; + } + + endsWith(suffix: string): StringSchema { + const clone = this._clone(); + clone._endsWith = suffix; + return clone; + } + + includes(substring: string): StringSchema { + const clone = this._clone(); + clone._includes = substring; + return clone; + } + + uppercase(): StringSchema { + const clone = this._clone(); + clone._uppercase = true; + return clone; + } + + lowercase(): StringSchema { + const clone = this._clone(); + clone._lowercase = true; + return clone; + } + + trim(): StringSchema { + const clone = this._clone(); + clone._trim = true; + return clone; + } + + toLowerCase(): StringSchema { + const clone = this._clone(); + clone._toLowerCase = true; + return clone; + } + + toUpperCase(): StringSchema { + const clone = this._clone(); + clone._toUpperCase = true; + return clone; + } + + normalize(): StringSchema { + const clone = this._clone(); + clone._normalize = true; + return clone; + } + + _schemaType(): SchemaType { + return SchemaType.String; + } + + _toJSONSchema(_tracker: RefTracker): JSONSchemaObject { + const schema: JSONSchemaObject = { type: 'string' }; + if (this._min !== undefined) schema.minLength = this._min; + if (this._max !== undefined) schema.maxLength = this._max; + if (this._regex !== undefined) schema.pattern = this._regex.source; + return schema; + } + + _clone(): StringSchema { + const clone = new StringSchema(); + clone._id = this._id; + clone._description = this._description; + clone._meta = this._meta ? { ...this._meta } : undefined; + clone._examples = [...this._examples]; + clone._min = this._min; + clone._minMessage = this._minMessage; + clone._max = this._max; + clone._maxMessage = this._maxMessage; + clone._length = this._length; + clone._regex = this._regex; + clone._startsWith = this._startsWith; + clone._endsWith = this._endsWith; + clone._includes = this._includes; + clone._uppercase = this._uppercase; + clone._lowercase = this._lowercase; + clone._trim = this._trim; + clone._toLowerCase = this._toLowerCase; + clone._toUpperCase = this._toUpperCase; + clone._normalize = this._normalize; + return clone; + } +} From 084a663069eda77cdd1d5499e6a2bf375a485d51 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Thu, 5 Feb 2026 23:11:48 -0300 Subject: [PATCH 2/2] fix(schema): add custom error messages to gt(), lt(), length(), and improve regex message Address PR #13 review feedback: consistent custom message support across all constraint methods and descriptive regex failure messages. Co-Authored-By: Claude Opus 4.6 --- .../schema/src/schemas/__tests__/number.test.ts | 16 ++++++++++++++++ .../schema/src/schemas/__tests__/string.test.ts | 17 +++++++++++++++-- packages/schema/src/schemas/number.ts | 14 ++++++++++---- packages/schema/src/schemas/string.ts | 9 ++++++--- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/schema/src/schemas/__tests__/number.test.ts b/packages/schema/src/schemas/__tests__/number.test.ts index 722bbaa37..d8a8121ff 100644 --- a/packages/schema/src/schemas/__tests__/number.test.ts +++ b/packages/schema/src/schemas/__tests__/number.test.ts @@ -84,6 +84,22 @@ describe('NumberSchema', () => { expect(() => fin.parse(-Infinity)).toThrow(ParseError); }); + it('.gt() and .lt() support custom error messages', () => { + const gtSchema = new NumberSchema().gt(5, 'Must be above 5'); + const gtResult = gtSchema.safeParse(5); + expect(gtResult.success).toBe(false); + if (!gtResult.success) { + expect(gtResult.error.issues[0]!.message).toBe('Must be above 5'); + } + + const ltSchema = new NumberSchema().lt(10, 'Must be below 10'); + const ltResult = ltSchema.safeParse(10); + expect(ltResult.success).toBe(false); + if (!ltResult.success) { + expect(ltResult.error.issues[0]!.message).toBe('Must be below 10'); + } + }); + it('supports custom error messages and .toJSONSchema()', () => { const schema = new NumberSchema().gte(1, 'Must be at least 1').lte(100, 'Must be at most 100'); const result = schema.safeParse(0); diff --git a/packages/schema/src/schemas/__tests__/string.test.ts b/packages/schema/src/schemas/__tests__/string.test.ts index 576c39327..6870a4558 100644 --- a/packages/schema/src/schemas/__tests__/string.test.ts +++ b/packages/schema/src/schemas/__tests__/string.test.ts @@ -40,10 +40,23 @@ describe('StringSchema', () => { expect(() => schema.parse('abcde')).toThrow(ParseError); }); - it('.regex(pattern) accepts matching and rejects non-matching', () => { + it('.length(n, message) supports custom error message', () => { + const schema = new StringSchema().length(4, 'Must be 4 chars'); + const result = schema.safeParse('ab'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]!.message).toBe('Must be 4 chars'); + } + }); + + it('.regex(pattern) accepts matching, rejects non-matching with descriptive message', () => { const schema = new StringSchema().regex(/^[a-z]+$/); expect(schema.parse('hello')).toBe('hello'); - expect(() => schema.parse('Hello123')).toThrow(ParseError); + const result = schema.safeParse('Hello123'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]!.message).toBe('Invalid: must match /^[a-z]+$/'); + } }); it('.startsWith(), .endsWith(), .includes() validate substrings', () => { diff --git a/packages/schema/src/schemas/number.ts b/packages/schema/src/schemas/number.ts index cdda8604c..2f0abbeb4 100644 --- a/packages/schema/src/schemas/number.ts +++ b/packages/schema/src/schemas/number.ts @@ -9,9 +9,11 @@ export class NumberSchema extends Schema { private _gte: number | undefined; private _gteMessage: string | undefined; private _gt: number | undefined; + private _gtMessage: string | undefined; private _lte: number | undefined; private _lteMessage: string | undefined; private _lt: number | undefined; + private _ltMessage: string | undefined; private _int: boolean = false; private _positive: boolean = false; private _negative: boolean = false; @@ -29,13 +31,13 @@ export class NumberSchema extends Schema { ctx.addIssue({ code: ErrorCode.TooSmall, message: this._gteMessage ?? `Number must be greater than or equal to ${this._gte}` }); } if (this._gt !== undefined && value <= this._gt) { - ctx.addIssue({ code: ErrorCode.TooSmall, message: `Number must be greater than ${this._gt}` }); + ctx.addIssue({ code: ErrorCode.TooSmall, message: this._gtMessage ?? `Number must be greater than ${this._gt}` }); } if (this._lte !== undefined && value > this._lte) { ctx.addIssue({ code: ErrorCode.TooBig, message: this._lteMessage ?? `Number must be less than or equal to ${this._lte}` }); } if (this._lt !== undefined && value >= this._lt) { - ctx.addIssue({ code: ErrorCode.TooBig, message: `Number must be less than ${this._lt}` }); + ctx.addIssue({ code: ErrorCode.TooBig, message: this._ltMessage ?? `Number must be less than ${this._lt}` }); } if (this._int && !Number.isInteger(value)) { ctx.addIssue({ code: ErrorCode.InvalidType, message: 'Expected integer, received float' }); @@ -72,9 +74,10 @@ export class NumberSchema extends Schema { return this.gte(n, message); } - gt(n: number): NumberSchema { + gt(n: number, message?: string): NumberSchema { const clone = this._clone(); clone._gt = n; + clone._gtMessage = message; return clone; } @@ -89,9 +92,10 @@ export class NumberSchema extends Schema { return this.lte(n, message); } - lt(n: number): NumberSchema { + lt(n: number, message?: string): NumberSchema { const clone = this._clone(); clone._lt = n; + clone._ltMessage = message; return clone; } @@ -164,9 +168,11 @@ export class NumberSchema extends Schema { clone._gte = this._gte; clone._gteMessage = this._gteMessage; clone._gt = this._gt; + clone._gtMessage = this._gtMessage; clone._lte = this._lte; clone._lteMessage = this._lteMessage; clone._lt = this._lt; + clone._ltMessage = this._ltMessage; clone._int = this._int; clone._positive = this._positive; clone._negative = this._negative; diff --git a/packages/schema/src/schemas/string.ts b/packages/schema/src/schemas/string.ts index 1092b9980..bbf07a33f 100644 --- a/packages/schema/src/schemas/string.ts +++ b/packages/schema/src/schemas/string.ts @@ -11,6 +11,7 @@ export class StringSchema extends Schema { private _max: number | undefined; private _maxMessage: string | undefined; private _length: number | undefined; + private _lengthMessage: string | undefined; private _regex: RegExp | undefined; private _startsWith: string | undefined; private _endsWith: string | undefined; @@ -47,10 +48,10 @@ export class StringSchema extends Schema { ctx.addIssue({ code: ErrorCode.TooBig, message: this._maxMessage ?? `String must contain at most ${this._max} character(s)` }); } if (this._length !== undefined && v.length !== this._length) { - ctx.addIssue({ code: ErrorCode.InvalidString, message: `String must be exactly ${this._length} character(s)` }); + ctx.addIssue({ code: ErrorCode.InvalidString, message: this._lengthMessage ?? `String must be exactly ${this._length} character(s)` }); } if (this._regex !== undefined && !this._regex.test(v)) { - ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid` }); + ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid: must match ${this._regex}` }); } if (this._startsWith !== undefined && !v.startsWith(this._startsWith)) { ctx.addIssue({ code: ErrorCode.InvalidString, message: `Invalid input: must start with "${this._startsWith}"` }); @@ -84,9 +85,10 @@ export class StringSchema extends Schema { return clone; } - length(n: number): StringSchema { + length(n: number, message?: string): StringSchema { const clone = this._clone(); clone._length = n; + clone._lengthMessage = message; return clone; } @@ -173,6 +175,7 @@ export class StringSchema extends Schema { clone._max = this._max; clone._maxMessage = this._maxMessage; clone._length = this._length; + clone._lengthMessage = this._lengthMessage; clone._regex = this._regex; clone._startsWith = this._startsWith; clone._endsWith = this._endsWith;