Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
119 changes: 119 additions & 0 deletions packages/schema/src/schemas/__tests__/number.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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('.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);
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,
});
});
});
122 changes: 122 additions & 0 deletions packages/schema/src/schemas/__tests__/string.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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('.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');
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', () => {
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]+$',
});
});
});
Loading