@scalar/validation
Advanced tools
+6
-0
| # @scalar/validation | ||
| ## 0.3.1 | ||
| ### Patch Changes | ||
| - [#9017](https://github.com/scalar/scalar/pull/9017): chore(validation): include package publish files | ||
| ## 0.3.0 | ||
@@ -4,0 +10,0 @@ |
+6
-2
@@ -18,3 +18,3 @@ { | ||
| ], | ||
| "version": "0.3.0", | ||
| "version": "0.3.1", | ||
| "engines": { | ||
@@ -31,2 +31,6 @@ "node": ">=20" | ||
| }, | ||
| "files": [ | ||
| "dist", | ||
| "CHANGELOG.md" | ||
| ], | ||
| "devDependencies": { | ||
@@ -39,5 +43,5 @@ "vite": "8.0.0", | ||
| "dev": "tsx playground/index.ts", | ||
| "test": "vitest", | ||
| "test": "vitest --run", | ||
| "types:check": "tsc --noEmit" | ||
| } | ||
| } |
| > @scalar/validation@0.3.0 build /home/runner/work/scalar/scalar/packages/validation | ||
| > tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json | ||
-1158
| import { describe, expect, it } from 'vitest' | ||
| import { coerce } from '@/coerce' | ||
| import { | ||
| any, | ||
| array, | ||
| boolean, | ||
| evaluate, | ||
| intersection, | ||
| lazy, | ||
| literal, | ||
| notDefined, | ||
| nullable, | ||
| number, | ||
| object, | ||
| optional, | ||
| record, | ||
| string, | ||
| union, | ||
| } from '@/schema' | ||
| describe('any', () => { | ||
| const T = any() | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = false | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = { a: 1, b: 2 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ a: 1, b: 2 }) | ||
| }) | ||
| }) | ||
| describe('array', () => { | ||
| const T = array(number()) | ||
| const E: number[] = [] | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual([1]) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = [6, 7, 8] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual([6, 7, 8]) | ||
| }) | ||
| }) | ||
| describe('boolean', () => { | ||
| const T = boolean() | ||
| const E = false | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 0 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| }) | ||
| describe('literal', () => { | ||
| const T = literal('hello') | ||
| const E = 'hello' | ||
| it('Should upcast from string', () => { | ||
| const value = 'world' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should preseve', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe('hello') | ||
| }) | ||
| }) | ||
| describe('null', () => { | ||
| const T = nullable() | ||
| const E = null | ||
| it('Should upcast from string', () => { | ||
| const value = 'world' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should preseve', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(null) | ||
| }) | ||
| }) | ||
| describe('number', () => { | ||
| const T = number() | ||
| const E = 0 | ||
| it('Should upcast from string', () => { | ||
| const value = 'world' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(1) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should preseve', () => { | ||
| const value = 123 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(123) | ||
| }) | ||
| }) | ||
| describe('object', () => { | ||
| const T = object({ | ||
| a: number(), | ||
| b: number(), | ||
| c: number(), | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| }) | ||
| const E = { | ||
| x: 0, | ||
| y: 0, | ||
| z: 0, | ||
| a: 0, | ||
| b: 0, | ||
| c: 0, | ||
| } | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = E | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = { x: 7, y: 8, z: 9, a: 10, b: 11, c: 12 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ | ||
| x: 7, | ||
| y: 8, | ||
| z: 9, | ||
| a: 10, | ||
| b: 11, | ||
| c: 12, | ||
| }) | ||
| }) | ||
| it('Should upcast partial object with incorrect properties', () => { | ||
| const value = { x: {}, y: 8, z: 9 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ | ||
| x: 0, | ||
| y: 8, | ||
| z: 9, | ||
| a: 0, | ||
| b: 0, | ||
| c: 0, | ||
| }) | ||
| }) | ||
| it('Should upcast and preserve partial object and omit unknown properties', () => { | ||
| const value = { x: 7, y: 8, z: 9, unknown: 'foo' } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ | ||
| x: 7, | ||
| y: 8, | ||
| z: 9, | ||
| a: 0, | ||
| b: 0, | ||
| c: 0, | ||
| }) | ||
| }) | ||
| it('Should upcast and remove additional properties', () => { | ||
| const result = coerce( | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| }), | ||
| { | ||
| x: 1, | ||
| y: 2, | ||
| z: { b: 1 }, | ||
| }, | ||
| ) | ||
| expect(result).toEqual({ | ||
| x: 1, | ||
| y: 2, | ||
| }) | ||
| }) | ||
| it('omits optional properties when the value is undefined', () => { | ||
| const T = object({ | ||
| id: number(), | ||
| name: optional(string()), | ||
| }) | ||
| expect(coerce(T, { id: 1 })).toEqual({ id: 1 }) | ||
| expect(coerce(T, { id: 1, name: undefined })).toEqual({ id: 1 }) | ||
| expect(coerce(T, { id: 1, name: 'x' })).toEqual({ id: 1, name: 'x' }) | ||
| }) | ||
| }) | ||
| describe('record', () => { | ||
| const T = record( | ||
| string(), | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| }), | ||
| ) | ||
| const E = {} | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = E | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = { | ||
| a: { x: 1, y: 2, z: 3 }, | ||
| b: { x: 4, y: 5, z: 6 }, | ||
| } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(value) | ||
| }) | ||
| it('Should preserve and patch invalid records', () => { | ||
| const value = { | ||
| a: { x: 1, y: 2, z: 3 }, | ||
| b: { x: 4, y: 5, z: {} }, | ||
| c: [1, 2, 3], | ||
| d: 1, | ||
| e: { x: 1, y: 2, w: 9000 }, | ||
| } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ | ||
| a: { x: 1, y: 2, z: 3 }, | ||
| b: { x: 4, y: 5, z: 0 }, | ||
| c: { x: 0, y: 0, z: 0 }, | ||
| d: { x: 0, y: 0, z: 0 }, | ||
| e: { x: 1, y: 2, z: 0 }, | ||
| }) | ||
| }) | ||
| }) | ||
| describe('string', () => { | ||
| const T = string() | ||
| const E = '' | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe('hello') | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = 'foo' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(value) | ||
| }) | ||
| }) | ||
| describe('union', () => { | ||
| const A = object({ | ||
| type: literal('A'), | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| }) | ||
| const B = object({ | ||
| type: literal('B'), | ||
| a: string(), | ||
| b: string(), | ||
| c: string(), | ||
| }) | ||
| const T = union([A, B]) | ||
| const E = { | ||
| type: 'A', | ||
| x: 0, | ||
| y: 0, | ||
| z: 0, | ||
| } | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should preserve A', () => { | ||
| const value = { type: 'A', x: 1, y: 2, z: 3 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(value) | ||
| }) | ||
| it('Should preserve B', () => { | ||
| const value = { type: 'B', a: 'a', b: 'b', c: 'c' } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(value) | ||
| }) | ||
| it('Should infer through heuristics #1', () => { | ||
| const value = { type: 'A', a: 'a', b: 'b', c: 'c' } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'A', x: 0, y: 0, z: 0 }) | ||
| }) | ||
| it('Should infer through heuristics #2', () => { | ||
| const value = { type: 'B', x: 1, y: 2, z: 3 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'B', a: '', b: '', c: '' }) | ||
| }) | ||
| it('Should infer through heuristics #3', () => { | ||
| const value = { type: 'A', a: 'a', b: 'b', c: null } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'A', x: 0, y: 0, z: 0 }) | ||
| }) | ||
| it('Should infer through heuristics #4', () => { | ||
| const value = { type: 'B', x: 1, y: 2, z: {} } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'B', a: '', b: '', c: '' }) | ||
| }) | ||
| it('Should infer through heuristics #5', () => { | ||
| const value = { type: 'B', x: 1, y: 2, z: null } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'B', a: '', b: '', c: '' }) | ||
| }) | ||
| it('Should infer through heuristics #6', () => { | ||
| const value = { x: 1 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'A', x: 1, y: 0, z: 0 }) | ||
| }) | ||
| it('Should infer through heuristics #7', () => { | ||
| const value = { a: null } // property existing should contribute | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual({ type: 'B', a: '', b: '', c: '' }) | ||
| }) | ||
| it('should correctly score nested union types #1', () => { | ||
| const A = union([ | ||
| union([ | ||
| object({ | ||
| type: literal('a'), | ||
| name: string(), | ||
| in: string(), | ||
| }), | ||
| object({ | ||
| type: literal('b'), | ||
| description: optional(string()), | ||
| nested: object({ | ||
| a: string(), | ||
| b: optional(string()), | ||
| }), | ||
| }), | ||
| ]), | ||
| object({ | ||
| $ref: string(), | ||
| description: optional(string()), | ||
| }), | ||
| ]) | ||
| expect( | ||
| coerce(A, { | ||
| type: 'b', | ||
| description: 'Hello World', | ||
| nested: { | ||
| b: 'hello', | ||
| }, | ||
| }), | ||
| ).toEqual({ | ||
| type: 'b', | ||
| description: 'Hello World', | ||
| nested: { a: '', b: 'hello' }, | ||
| }) | ||
| }) | ||
| it('should correctly score nested union types #2', () => { | ||
| const A = union([ | ||
| union([ | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop3: string(), | ||
| }), | ||
| object({ | ||
| prop1: string(), | ||
| prop4: string(), | ||
| prop5: string(), | ||
| }), | ||
| ]), | ||
| union([ | ||
| object({ | ||
| prop6: string(), | ||
| prop7: string(), | ||
| prop8: string(), | ||
| }), | ||
| object({ | ||
| prop1: string(), | ||
| prop9: string(), | ||
| prop10: string(), | ||
| }), | ||
| ]), | ||
| ]) | ||
| // Picks the first union variant when the score is equal | ||
| expect( | ||
| coerce(A, { | ||
| prop1: '', | ||
| }), | ||
| ).toEqual({ | ||
| prop1: '', | ||
| prop2: '', | ||
| prop3: '', | ||
| }) | ||
| expect( | ||
| coerce(A, { | ||
| prop1: '', | ||
| prop4: '', | ||
| }), | ||
| ).toEqual({ | ||
| prop1: '', | ||
| prop4: '', | ||
| prop5: '', | ||
| }) | ||
| expect( | ||
| coerce(A, { | ||
| prop6: '', | ||
| }), | ||
| ).toEqual({ | ||
| prop6: '', | ||
| prop7: '', | ||
| prop8: '', | ||
| }) | ||
| }) | ||
| it('should correctly score nested union types #3', () => { | ||
| const A = union([ | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop3: string(), | ||
| }), | ||
| object({ | ||
| prop4: string(), | ||
| prop5: string(), | ||
| prop6: string(), | ||
| }), | ||
| union([ | ||
| object({ | ||
| prop4: string(), | ||
| prop5: string(), | ||
| prop6: string(), | ||
| }), | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop7: string(), | ||
| prop8: string(), | ||
| }), | ||
| ]), | ||
| ]) | ||
| expect( | ||
| coerce(A, { | ||
| prop1: '', | ||
| prop2: '', | ||
| prop7: '', | ||
| }), | ||
| ).toEqual({ | ||
| prop1: '', | ||
| prop2: '', | ||
| prop7: '', | ||
| prop8: '', | ||
| }) | ||
| }) | ||
| it('should correctly score nested union types #4', () => { | ||
| const A = union([ | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop3: string(), | ||
| }), | ||
| union([ | ||
| object({ | ||
| prop4: string(), | ||
| prop5: string(), | ||
| prop6: string(), | ||
| }), | ||
| union([ | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop7: string(), | ||
| prop8: string(), | ||
| }), | ||
| union([ | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop9: string(), | ||
| prop10: string(), | ||
| }), | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop11: string(), | ||
| prop12: string(), | ||
| }), | ||
| ]), | ||
| ]), | ||
| ]), | ||
| ]) | ||
| expect( | ||
| coerce(A, { | ||
| prop1: '', | ||
| prop2: '', | ||
| prop9: '', | ||
| }), | ||
| ).toEqual({ | ||
| prop1: '', | ||
| prop2: '', | ||
| prop9: '', | ||
| prop10: '', | ||
| }) | ||
| }) | ||
| it('should correctly score object unions with shared properties #1', () => { | ||
| const schema = union([ | ||
| object({ | ||
| summary: optional(string()), | ||
| description: optional(string()), | ||
| parameters: optional(array(any())), | ||
| responses: optional(record(string(), any())), | ||
| requestBody: optional(any()), | ||
| }), | ||
| object({ | ||
| $ref: string(), | ||
| summary: optional(string()), | ||
| }), | ||
| ]) | ||
| expect( | ||
| coerce(schema, { | ||
| summary: 'Test Summary', | ||
| parameters: {}, | ||
| }), | ||
| ).toEqual({ | ||
| summary: 'Test Summary', | ||
| parameters: [], | ||
| }) | ||
| }) | ||
| it('should correctly score object unions with shared properties #2', () => { | ||
| const A = union([ | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop3: string(), | ||
| }), | ||
| object({ | ||
| prop1: string(), | ||
| prop2: string(), | ||
| prop4: string(), | ||
| prop5: string(), | ||
| prop6: string(), | ||
| prop7: string(), | ||
| prop8: string(), | ||
| prop9: string(), | ||
| prop10: string(), | ||
| }), | ||
| ]) | ||
| expect( | ||
| coerce(A, { | ||
| prop1: '', | ||
| prop2: '', | ||
| prop7: '', | ||
| }), | ||
| ).toEqual({ | ||
| prop1: '', | ||
| prop2: '', | ||
| prop4: '', | ||
| prop5: '', | ||
| prop6: '', | ||
| prop7: '', | ||
| prop8: '', | ||
| prop9: '', | ||
| prop10: '', | ||
| }) | ||
| }) | ||
| it('should correctly score object union for objects with all optional properties', () => { | ||
| const A = union([ | ||
| object({ | ||
| prop1: optional(string()), | ||
| prop2: optional(string()), | ||
| prop3: optional(string()), | ||
| }), | ||
| object({ | ||
| $ref: string(), | ||
| }), | ||
| ]) | ||
| expect( | ||
| coerce(A, { | ||
| $ref: 'https://example.com/schema', | ||
| }), | ||
| ).toEqual({ | ||
| $ref: 'https://example.com/schema', | ||
| }) | ||
| }) | ||
| it('picks the branch whose type discriminator matches a union of literals', () => { | ||
| const T = union([ | ||
| object({ | ||
| type: literal('a'), | ||
| a: string(), | ||
| }), | ||
| object({ | ||
| type: union([literal('b'), literal('c')]), | ||
| b: string(), | ||
| }), | ||
| ]) | ||
| expect(coerce(T, { type: 'a' })).toEqual({ type: 'a', a: '' }) | ||
| expect(coerce(T, { type: 'b' })).toEqual({ type: 'b', b: '' }) | ||
| expect(coerce(T, { type: 'c' })).toEqual({ type: 'c', b: '' }) | ||
| }) | ||
| }) | ||
| describe('intersection', () => { | ||
| const T = intersection([ | ||
| object({ | ||
| a: number(), | ||
| b: number(), | ||
| }), | ||
| object({ | ||
| c: string(), | ||
| d: string(), | ||
| }), | ||
| ]) | ||
| it('merges coerced properties from each object schema', () => { | ||
| const result = coerce(T, { a: 1, b: 2, c: 'x', d: 'y' }) | ||
| expect(result).toEqual({ a: 1, b: 2, c: 'x', d: 'y' }) | ||
| }) | ||
| it('fills missing keys per branch from the same input value', () => { | ||
| const result = coerce(T, { a: 'nope', c: 123 }) | ||
| expect(result).toEqual({ a: 0, b: 0, c: '', d: '' }) | ||
| }) | ||
| it('later branch wins on overlapping keys', () => { | ||
| const overlap = intersection([object({ x: number() }), object({ x: string() })]) | ||
| const result = coerce(overlap, { x: 1 }) | ||
| expect(result).toEqual({ x: '' }) | ||
| }) | ||
| it('wins in a union when every member validates and summed score beats narrower members', () => { | ||
| const A = object({ type: literal('A'), onlyA: number() }) | ||
| const B = object({ type: literal('B'), onlyB: string() }) | ||
| const both = intersection([ | ||
| object({ type: literal('A'), shared: number() }), | ||
| object({ shared: number(), extra: string() }), | ||
| ]) | ||
| const T = union([A, B, both]) | ||
| // Intersection merges only its declared keys; it outscores A here because both sub-objects validate. | ||
| expect(coerce(T, { type: 'A', onlyA: 1, shared: 2, extra: 'ok' })).toEqual({ | ||
| type: 'A', | ||
| shared: 2, | ||
| extra: 'ok', | ||
| }) | ||
| }) | ||
| it('correctly picks the branch on an intersection with nested unions #1', () => { | ||
| const T = intersection([ | ||
| union([object({ type: literal('a'), a: string() }), object({ type: literal('b'), b: string() })]), | ||
| object({ c: string() }), | ||
| ]) | ||
| expect(coerce(T, { type: 'a', a: 'x', c: 'y' })).toEqual({ type: 'a', a: 'x', c: 'y' }) | ||
| expect(coerce(T, { type: 'b', b: 'x', c: 'y' })).toEqual({ type: 'b', b: 'x', c: 'y' }) | ||
| expect(coerce(T, { c: 'y' })).toEqual({ type: 'a', a: '', c: 'y' }) | ||
| }) | ||
| it('correctly picks the branch on an intersection with nested unions #2', () => { | ||
| const T = intersection([ | ||
| union([ | ||
| object({ type: literal('a'), a: optional(string()) }), | ||
| object({ type: literal('b'), b: optional(string()) }), | ||
| ]), | ||
| object({ c: optional(string()), d: optional(string()) }), | ||
| ]) | ||
| expect(coerce(T, { a: 'a' })).toEqual({ type: 'a', a: 'a' }) | ||
| expect(coerce(T, { a: 'a', c: 'c' })).toEqual({ type: 'a', a: 'a', c: 'c' }) | ||
| }) | ||
| }) | ||
| describe('notDefined', () => { | ||
| const T = notDefined() | ||
| const E = undefined | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toBe(undefined) | ||
| }) | ||
| }) | ||
| describe('lazy', () => { | ||
| const T = lazy(() => object({ x: number() })) | ||
| const E = { x: 0 } | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = { x: 1 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(value) | ||
| }) | ||
| }) | ||
| describe('evaluate', () => { | ||
| const T = evaluate((value) => value, object({ x: number() })) | ||
| const E = { x: 0 } | ||
| it('Should upcast from string', () => { | ||
| const value = 'hello' | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from number', () => { | ||
| const value = 1 | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from boolean', () => { | ||
| const value = true | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from object', () => { | ||
| const value = {} | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from array', () => { | ||
| const value = [1] | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from undefined', () => { | ||
| const value = undefined | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from null', () => { | ||
| const value = null | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should upcast from date', () => { | ||
| const value = new Date(100) | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(E) | ||
| }) | ||
| it('Should preserve', () => { | ||
| const value = { x: 1 } | ||
| const result = coerce(T, value) | ||
| expect(result).toEqual(value) | ||
| }) | ||
| }) |
-224
| import { isObject } from './helpers/is-object' | ||
| import type { Schema } from './schema' | ||
| import type { Static } from './types' | ||
| import { validate } from './validate' | ||
| /** | ||
| * True when this property schema is only used to discriminate union branches | ||
| * (single literal, or a union of literals). No presence bonus when the value | ||
| * does not match — avoids ties like `type: literal('a')` vs `type: union([lit('b'), lit('c')])`. | ||
| */ | ||
| const isDiscriminatorProperty = (schema: Schema): boolean => { | ||
| if (schema.type === 'optional') { | ||
| return isDiscriminatorProperty(schema.schema) | ||
| } | ||
| if (schema.type === 'literal') { | ||
| return true | ||
| } | ||
| if (schema.type === 'union') { | ||
| return schema.schemas.length > 0 && schema.schemas.every(isDiscriminatorProperty) | ||
| } | ||
| return false | ||
| } | ||
| /** | ||
| * Computes a "score" indicating how well a value matches a schema, | ||
| * used for picking the best branch in union coercion. | ||
| * | ||
| * Higher score means a closer match. Literals and matching object shapes | ||
| * are weighted more heavily. Objects are scored by shape/literals; | ||
| * arrays/records by structural type; primitives by validation; unions try all branches. | ||
| */ | ||
| const scoreUnion = (schema: Schema, value: unknown): number => { | ||
| if (schema.type === 'object') { | ||
| if (!isObject(value)) { | ||
| return 0 | ||
| } | ||
| // Missing keys contribute 0 (including optional keys — matches prior union heuristics). | ||
| // Discriminator properties (`literal` or `union` of literals): recurse with scoreUnion; | ||
| // matching values get a high weight (×10) so `type: literal('A')` beats unrelated fields | ||
| // on another branch; mismatches score 0 (no "key present" tie-break). | ||
| // Other properties: scoreUnion plus +1 when the value fails validation so `{ a: null }` | ||
| // can still prefer the branch that declares `a`. | ||
| return Object.keys(schema.properties).reduce<number>((acc, key) => { | ||
| if (!(key in value)) { | ||
| return acc | ||
| } | ||
| const propSchema = schema.properties[key] | ||
| const raw = value[key as keyof typeof value] | ||
| const base = scoreUnion(propSchema, raw) | ||
| if (isDiscriminatorProperty(propSchema)) { | ||
| return acc + (base > 0 ? base * 10 : 0) | ||
| } | ||
| return acc + (base > 0 ? base : 1) | ||
| }, 0) | ||
| } | ||
| if (schema.type === 'array') { | ||
| // Score 1 if value is an array, otherwise 0 | ||
| return Array.isArray(value) ? 1 : 0 | ||
| } | ||
| if (schema.type === 'record') { | ||
| // TODO: implement smarter scoring for records (just a placeholder for now) | ||
| return isObject(value) ? 1 : 0 | ||
| } | ||
| if (schema.type === 'optional') { | ||
| return value === undefined ? 1 : scoreUnion(schema.schema, value) | ||
| } | ||
| if (schema.type === 'union') { | ||
| // For a union, use the highest score among all sub-schemas | ||
| return Math.max(...schema.schemas.map((schema) => scoreUnion(schema, value))) | ||
| } | ||
| if (schema.type === 'intersection') { | ||
| if (schema.schemas.length === 0) { | ||
| return 1 | ||
| } | ||
| return schema.schemas.reduce((acc, sub) => acc + scoreUnion(sub, value), 0) | ||
| } | ||
| if (schema.type === 'lazy') { | ||
| // For a lazy schema, evaluate the inner schema and recurse | ||
| return scoreUnion(schema.schema(), value) | ||
| } | ||
| if (schema.type === 'evaluate') { | ||
| // For an evaluate schema, evaluate the expression and recurse | ||
| return scoreUnion(schema.schema, schema.expression(value)) | ||
| } | ||
| // For primitives and any other type, return 1 if valid, otherwise 0 | ||
| return validate(schema, value) ? 1 : 0 | ||
| } | ||
| /** | ||
| * Coerces an unknown value toward the static type implied by `schema`. Values that | ||
| * pass {@link validate} for that branch are kept; otherwise primitives default to | ||
| * `0`, `''`, or `false`, and arrays, records, and objects are built recursively. | ||
| * Unions pick the best-matching branch; `evaluate` runs `expression` before the inner schema. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { coerce, number, object, string } from '@scalar/validation' | ||
| * | ||
| * coerce(number(), 42) // 42 | ||
| * coerce(number(), 'nope') // 0 — invalid number uses default | ||
| * coerce(object({ id: number(), name: string() }), { id: '1', name: 'Ada' }) // { id: 0, name: 'Ada' } | ||
| * ``` | ||
| * | ||
| * The optional `cache` argument tracks visited object–schema pairs to stop infinite recursion | ||
| * on cyclic graphs; callers normally omit it. | ||
| */ | ||
| export const coerce = <S extends Schema>( | ||
| schema: S, | ||
| value: unknown, | ||
| cache: WeakMap<object, Set<Schema>> = new WeakMap(), | ||
| ): Static<S> => { | ||
| // Prevent infinite recursion | ||
| if (isObject(value) && cache.get(value)?.has(schema)) { | ||
| return value as Static<S> | ||
| } | ||
| // Track visited schemas to prevent infinite recursion | ||
| if (isObject(value)) { | ||
| const schemas = cache.get(value) || new Set<Schema>() | ||
| schemas.add(schema) | ||
| cache.set(value, schemas) | ||
| } | ||
| // If no schema is provided, return the value as is | ||
| if (!schema) { | ||
| return value as Static<S> | ||
| } | ||
| if (schema.type === 'any') { | ||
| return value as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'number') { | ||
| if (validate(schema, value)) { | ||
| return value as Static<S> | ||
| } | ||
| return 0 as Static<S> | ||
| } | ||
| if (schema.type === 'string') { | ||
| if (validate(schema, value)) { | ||
| return value as unknown as Static<S> | ||
| } | ||
| return '' as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'boolean') { | ||
| if (validate(schema, value)) { | ||
| return value as unknown as Static<S> | ||
| } | ||
| return false as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'nullable') { | ||
| return null as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'notDefined') { | ||
| return undefined as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'optional') { | ||
| if (value === undefined) { | ||
| return undefined as unknown as Static<S> | ||
| } | ||
| return coerce(schema.schema, value, cache) | ||
| } | ||
| if (schema.type === 'array') { | ||
| if (!Array.isArray(value)) { | ||
| return [] as unknown as Static<S> | ||
| } | ||
| return value.map((item) => coerce(schema.items, item, cache)) as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'record') { | ||
| if (!isObject(value)) { | ||
| return {} as unknown as Static<S> | ||
| } | ||
| return Object.fromEntries( | ||
| Object.entries(value).map(([key, value]) => [key, coerce(schema.value, value, cache)]), | ||
| ) as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'object') { | ||
| const keys = Object.keys(schema.properties) | ||
| const target = isObject(value) ? value : null | ||
| const entries: [string, unknown][] = [] | ||
| for (const key of keys) { | ||
| const propSchema = schema.properties[key] | ||
| const raw = target?.[key as keyof typeof target] | ||
| if (propSchema.type === 'optional' && raw === undefined) { | ||
| continue | ||
| } | ||
| entries.push([key, coerce(propSchema, raw, cache)]) | ||
| } | ||
| return Object.fromEntries(entries) as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'union') { | ||
| const branch = schema.schemas.reduce( | ||
| (acc, schema) => { | ||
| const score = scoreUnion(schema, value) | ||
| return score > acc.score ? { schema, score } : acc | ||
| }, | ||
| { schema: schema.schemas[0], score: 0 }, | ||
| ) | ||
| // We need some way to pick one of the union values | ||
| return coerce(branch.schema, value, cache) | ||
| } | ||
| if (schema.type === 'intersection') { | ||
| return schema.schemas.reduce<Record<string, unknown>>( | ||
| (acc, subSchema) => Object.assign(acc, coerce(subSchema, value, cache) as Record<string, unknown>), | ||
| {}, | ||
| ) as unknown as Static<S> | ||
| } | ||
| if (schema.type === 'literal') { | ||
| return schema.value | ||
| } | ||
| if (schema.type === 'lazy') { | ||
| return coerce(schema.schema(), value, cache) | ||
| } | ||
| if (schema.type === 'evaluate') { | ||
| return coerce(schema.schema, schema.expression(value), cache) | ||
| } | ||
| // We need to assert here that schema has the type never so we know we handle all cases | ||
| const _exhaustive: never = schema | ||
| console.warn('Unknown schema type:', _exhaustive) | ||
| return value as unknown as Static<S> | ||
| } |
| import { describe, expect, it } from 'vitest' | ||
| import { isObject } from './is-object' | ||
| describe('isObject', () => { | ||
| it('returns true for plain empty objects', () => { | ||
| expect(isObject({})).toBe(true) | ||
| }) | ||
| it('returns true for objects with properties', () => { | ||
| expect(isObject({ a: 1 })).toBe(true) | ||
| expect(isObject({ a: 1, b: 2, c: 3 })).toBe(true) | ||
| }) | ||
| it('returns true for objects with different value types', () => { | ||
| const obj = { | ||
| string: 'hello', | ||
| number: 42, | ||
| boolean: true, | ||
| null: null, | ||
| undefined: undefined, | ||
| array: [], | ||
| nested: { x: 1 }, | ||
| } | ||
| expect(isObject(obj)).toBe(true) | ||
| }) | ||
| it('returns true for objects created with Object.create(null)', () => { | ||
| const obj = Object.create(null) | ||
| expect(isObject(obj)).toBe(true) | ||
| }) | ||
| it('returns false for Date objects', () => { | ||
| expect(isObject(new Date())).toBe(false) | ||
| }) | ||
| it('returns false for RegExp objects', () => { | ||
| expect(isObject(/test/)).toBe(false) | ||
| expect(isObject(new RegExp('test'))).toBe(false) | ||
| }) | ||
| it('returns false for Error objects', () => { | ||
| expect(isObject(new Error('test'))).toBe(false) | ||
| }) | ||
| it('returns false for Map objects', () => { | ||
| expect(isObject(new Map())).toBe(false) | ||
| }) | ||
| it('returns false for Set objects', () => { | ||
| expect(isObject(new Set())).toBe(false) | ||
| }) | ||
| it('returns false for WeakMap objects', () => { | ||
| expect(isObject(new WeakMap())).toBe(false) | ||
| }) | ||
| it('returns false for WeakSet objects', () => { | ||
| expect(isObject(new WeakSet())).toBe(false) | ||
| }) | ||
| it('returns false for Promise objects', () => { | ||
| expect(isObject(Promise.resolve())).toBe(false) | ||
| }) | ||
| it('returns false for arrays', () => { | ||
| expect(isObject([])).toBe(false) | ||
| expect(isObject([1, 2, 3])).toBe(false) | ||
| expect(isObject(new Array(10))).toBe(false) | ||
| }) | ||
| it('returns false for null', () => { | ||
| expect(isObject(null)).toBe(false) | ||
| }) | ||
| it('returns false for undefined', () => { | ||
| expect(isObject(undefined)).toBe(false) | ||
| }) | ||
| it('returns false for numbers', () => { | ||
| expect(isObject(0)).toBe(false) | ||
| expect(isObject(123)).toBe(false) | ||
| expect(isObject(-456)).toBe(false) | ||
| expect(isObject(3.14)).toBe(false) | ||
| expect(isObject(Number.NaN)).toBe(false) | ||
| expect(isObject(Number.POSITIVE_INFINITY)).toBe(false) | ||
| expect(isObject(Number.NEGATIVE_INFINITY)).toBe(false) | ||
| }) | ||
| it('returns false for strings', () => { | ||
| expect(isObject('')).toBe(false) | ||
| expect(isObject('string')).toBe(false) | ||
| expect(isObject('hello world')).toBe(false) | ||
| }) | ||
| it('returns false for booleans', () => { | ||
| expect(isObject(true)).toBe(false) | ||
| expect(isObject(false)).toBe(false) | ||
| }) | ||
| it('returns false for functions', () => { | ||
| expect( | ||
| isObject(() => { | ||
| return | ||
| }), | ||
| ).toBe(false) | ||
| expect( | ||
| isObject(() => { | ||
| return | ||
| }), | ||
| ).toBe(false) | ||
| expect( | ||
| isObject(async () => { | ||
| return await Promise.resolve() | ||
| }), | ||
| ).toBe(false) | ||
| }) | ||
| it('returns false for symbols', () => { | ||
| expect(isObject(Symbol('test'))).toBe(false) | ||
| expect(isObject(Symbol.for('test'))).toBe(false) | ||
| }) | ||
| it('returns false for BigInt values', () => { | ||
| expect(isObject(BigInt(123))).toBe(false) | ||
| expect(isObject(123n)).toBe(false) | ||
| }) | ||
| it('works correctly as a type guard', () => { | ||
| const value: unknown = { a: 1, b: 2 } | ||
| if (isObject(value)) { | ||
| // TypeScript should narrow the type to Record<string, unknown> | ||
| const keys = Object.keys(value) | ||
| expect(keys).toEqual(['a', 'b']) | ||
| expect(value.a).toBe(1) | ||
| } | ||
| }) | ||
| it('handles objects with nested structures', () => { | ||
| const obj = { | ||
| nested: { | ||
| deeper: { | ||
| value: 'test', | ||
| }, | ||
| }, | ||
| } | ||
| expect(isObject(obj)).toBe(true) | ||
| }) | ||
| it('returns false for class instances', () => { | ||
| class CustomClass { | ||
| prop = 'value' | ||
| } | ||
| const instance = new CustomClass() | ||
| expect(isObject(instance)).toBe(false) | ||
| }) | ||
| it('handles frozen objects', () => { | ||
| const obj = Object.freeze({ a: 1 }) | ||
| expect(isObject(obj)).toBe(true) | ||
| }) | ||
| it('handles sealed objects', () => { | ||
| const obj = Object.seal({ a: 1 }) | ||
| expect(isObject(obj)).toBe(true) | ||
| }) | ||
| }) |
| /** | ||
| * Stolen from |@scalar/helpers/object/is-object.ts| | ||
| * so we don't have to depend on it. | ||
| */ | ||
| /** | ||
| * Returns true if the provided value is a record object | ||
| * (i.e. not null, not an array, and has an actual object as the prototype). | ||
| * | ||
| * Differs from the previous isObject in that it returns false for Date, | ||
| * RegExp, Error, Map, Set, WeakMap, WeakSet, Promise, and other non-plain objects. | ||
| * | ||
| * Examples: | ||
| * isObject({}) // true | ||
| * isObject({ a: 1 }) // true | ||
| * isObject([]) // false (Array) | ||
| * isObject(null) // false | ||
| * isObject(123) // false | ||
| * isObject('string') // false | ||
| * isObject(new Error('test')) // false | ||
| * isObject(new Date()) // false | ||
| * isObject(Object.create(null)) // true | ||
| */ | ||
| export const isObject = (value: unknown): value is Record<string | number | symbol, unknown> => { | ||
| if (value === null || typeof value !== 'object') { | ||
| return false | ||
| } | ||
| const proto = Object.getPrototypeOf(value) | ||
| return proto === Object.prototype || proto === null | ||
| } |
-37
| export { coerce } from './coerce' | ||
| export { | ||
| type AnySchema, | ||
| type ArraySchema, | ||
| type BooleanSchema, | ||
| type EvaluateSchema, | ||
| type IntersectionSchema, | ||
| type LazySchema, | ||
| type LiteralSchema, | ||
| type NotDefinedSchema, | ||
| type NullableSchema, | ||
| type NumberSchema, | ||
| type ObjectSchema, | ||
| type OptionalSchema, | ||
| type RecordSchema, | ||
| type Schema, | ||
| type StringSchema, | ||
| type UnionSchema, | ||
| any, | ||
| array, | ||
| boolean, | ||
| evaluate, | ||
| intersection, | ||
| lazy, | ||
| literal, | ||
| notDefined, | ||
| nullable, | ||
| number, | ||
| object, | ||
| optional, | ||
| record, | ||
| string, | ||
| union, | ||
| } from './schema' | ||
| export { type GenerateTypesOptions, generateTypes } from './typegen' | ||
| export type { Static } from './types' | ||
| export { validate } from './validate' |
-250
| /** | ||
| * Optional metadata for type generation and documentation. | ||
| * - typeName: Used as the exported TypeScript type name if valid. | ||
| * - typeComment: Adds a JSDoc comment to the generated type declaration. | ||
| */ | ||
| type Documentation = Partial<{ | ||
| /** Adds a JSDoc comment to the generated type declaration. */ | ||
| typeComment: string | ||
| /** Used as the exported TypeScript type name if valid. */ | ||
| typeName: string | ||
| }> | ||
| /** Schema for finite numeric values. {@link Static} resolves to `number`. */ | ||
| export type NumberSchema = { | ||
| type: 'number' | ||
| } & Documentation | ||
| /** Schema for string values. {@link Static} resolves to `string`. */ | ||
| export type StringSchema = { | ||
| type: 'string' | ||
| } & Documentation | ||
| /** Schema for boolean values. {@link Static} resolves to `boolean`. */ | ||
| export type BooleanSchema = { | ||
| type: 'boolean' | ||
| } & Documentation | ||
| /** Schema for `null`. {@link Static} resolves to `null`. */ | ||
| export type NullableSchema = { | ||
| type: 'nullable' | ||
| } & Documentation | ||
| /** Schema for a missing or omitted value. {@link Static} resolves to `undefined`. */ | ||
| export type NotDefinedSchema = { | ||
| type: 'notDefined' | ||
| } & Documentation | ||
| /** Schema that accepts any value without narrowing. {@link Static} resolves to `any`. */ | ||
| export type AnySchema = { | ||
| type: 'any' | ||
| } & Documentation | ||
| /** Schema for homogeneous lists. {@link Static} resolves to an array of the item static type. */ | ||
| export type ArraySchema<Item extends Schema> = { | ||
| type: 'array' | ||
| items: Item | ||
| } & Documentation | ||
| /** Schema for key-value maps with uniform value shape. Keys are constrained to string or number schemas. */ | ||
| export type RecordSchema<Key extends StringSchema | NumberSchema | AnySchema, Value extends Schema> = { | ||
| type: 'record' | ||
| key: Key | ||
| value: Value | ||
| } & Documentation | ||
| /** Schema for objects with a fixed set of named properties, each with its own schema. */ | ||
| export type ObjectSchema<Properties extends Record<string, Schema>> = { | ||
| type: 'object' | ||
| properties: Properties | ||
| } & Documentation | ||
| /** Schema that matches if any member schema matches (discriminated union when literals or object tags differ). */ | ||
| export type UnionSchema<Schemas extends Schema[]> = { | ||
| type: 'union' | ||
| schemas: Schemas | ||
| } & Documentation | ||
| /** | ||
| * Schema that accepts `undefined` or a value matching the inner schema. | ||
| * In {@link Static} and type generation, object properties use `key?:` instead of `T | undefined`. | ||
| */ | ||
| export type OptionalSchema<S extends Schema> = { | ||
| type: 'optional' | ||
| schema: S | ||
| } & Documentation | ||
| /** | ||
| * `UnionSchema<any>` avoids a variance pitfall: `UnionSchema<[A, B]>` is not assignable to | ||
| * `UnionSchema<ObjectSchema<any>[]>`, which breaks `infer` when resolving `Static`. | ||
| */ | ||
| export type IntersectionSchema<Schemas extends readonly (ObjectSchema<any> | UnionSchema<any>)[]> = { | ||
| type: 'intersection' | ||
| schemas: Schemas | ||
| } & Documentation | ||
| /** Schema for a single exact constant (string, number, boolean, or bigint). {@link Static} is that literal type. */ | ||
| export type LiteralSchema<T extends string | number | boolean | bigint> = { | ||
| type: 'literal' | ||
| value: T | ||
| } & Documentation | ||
| /** | ||
| * Schema for self-referential or recursive types (such as trees or linked lists). | ||
| * The `schema` property is a factory function returning a schema instance, allowing | ||
| * references to itself without causing circular definition errors at type-level. | ||
| */ | ||
| export type LazySchema<S extends () => Schema> = { | ||
| type: 'lazy' | ||
| schema: S | ||
| } | ||
| /** | ||
| * Schema that runs a coercion or transform (`expression`) on the input, then validates with the inner schema. | ||
| * Use when parsing needs a preprocessing step before the usual rules apply. | ||
| */ | ||
| export type EvaluateSchema<S extends Schema> = { | ||
| type: 'evaluate' | ||
| expression: (value: unknown) => unknown | ||
| schema: S | ||
| } | ||
| export type Schema = | ||
| | NumberSchema | ||
| | StringSchema | ||
| | BooleanSchema | ||
| | NullableSchema | ||
| | NotDefinedSchema | ||
| | AnySchema | ||
| | ArraySchema<any> | ||
| | RecordSchema<any, any> | ||
| | ObjectSchema<Record<string, any>> | ||
| | UnionSchema<any[]> | ||
| | OptionalSchema<any> | ||
| | IntersectionSchema<readonly (ObjectSchema<any> | UnionSchema<ObjectSchema<any>[]>)[]> | ||
| | LiteralSchema<any> | ||
| | LazySchema<any> | ||
| | EvaluateSchema<any> | ||
| const number = (options?: Documentation): NumberSchema => ({ | ||
| type: 'number', | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const string = (options?: Documentation): StringSchema => ({ | ||
| type: 'string', | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const boolean = (options?: Documentation): BooleanSchema => ({ | ||
| type: 'boolean', | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const nullable = (options?: Documentation): NullableSchema => ({ | ||
| type: 'nullable', | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const notDefined = (options?: Documentation): NotDefinedSchema => ({ | ||
| type: 'notDefined', | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const any = (options?: Documentation): AnySchema => ({ | ||
| type: 'any', | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const array = <Item extends Schema>(items: Item, options?: Documentation): ArraySchema<Item> => ({ | ||
| type: 'array', | ||
| items, | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const record = <Key extends StringSchema | AnySchema, Value extends Schema>( | ||
| key: Key, | ||
| value: Value, | ||
| options?: Documentation, | ||
| ): RecordSchema<Key, Value> => ({ | ||
| type: 'record', | ||
| key, | ||
| value, | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const object = <Properties extends Record<string, Schema>>( | ||
| properties: Properties, | ||
| options?: Documentation, | ||
| ): ObjectSchema<Properties> => ({ | ||
| type: 'object', | ||
| properties, | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const union = <Schemas extends Schema[]>(schemas: Schemas, options?: Documentation): UnionSchema<Schemas> => ({ | ||
| type: 'union', | ||
| schemas, | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const intersection = <const Schemas extends readonly (ObjectSchema<any> | UnionSchema<ObjectSchema<any>[]>)[]>( | ||
| schemas: Schemas, | ||
| options?: Documentation, | ||
| ): IntersectionSchema<Schemas> => ({ | ||
| type: 'intersection', | ||
| schemas, | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const optional = <S extends Schema>(schema: S, options?: Documentation): OptionalSchema<S> => ({ | ||
| type: 'optional', | ||
| schema, | ||
| typeName: options?.typeName, | ||
| typeComment: options?.typeComment, | ||
| }) | ||
| const literal = <Value extends string | number | boolean | bigint>(value: Value): LiteralSchema<Value> => ({ | ||
| type: 'literal', | ||
| value, | ||
| }) | ||
| const lazy = <S extends () => Schema>(schema: S): LazySchema<S> => ({ | ||
| type: 'lazy', | ||
| schema, | ||
| }) | ||
| const evaluate = <S extends Schema>(expression: (value: unknown) => unknown, schema: S): EvaluateSchema<S> => ({ | ||
| type: 'evaluate', | ||
| expression, | ||
| schema, | ||
| }) | ||
| export { | ||
| number, | ||
| string, | ||
| boolean, | ||
| nullable, | ||
| notDefined, | ||
| any, | ||
| array, | ||
| record, | ||
| object, | ||
| union, | ||
| intersection, | ||
| optional, | ||
| literal, | ||
| lazy, | ||
| evaluate, | ||
| } |
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| any, | ||
| array, | ||
| boolean, | ||
| evaluate, | ||
| lazy, | ||
| intersection, | ||
| literal, | ||
| notDefined, | ||
| nullable, | ||
| number, | ||
| object, | ||
| optional, | ||
| record, | ||
| string, | ||
| union, | ||
| } from '@/schema' | ||
| import { generateTypes } from '@/typegen' | ||
| const fixedGeneratedAt = '2000-01-01T00:00:00.000Z' | ||
| const autogeneratedBanner = `/**\n * This file is autogenerated. Do not edit it.\n *\n * Generated at: ${fixedGeneratedAt}\n */\n\n` | ||
| describe('typegen', () => { | ||
| it('emits primitives and special scalars', () => { | ||
| expect(generateTypes(number())).toBe('number') | ||
| expect(generateTypes(string())).toBe('string') | ||
| expect(generateTypes(boolean())).toBe('boolean') | ||
| expect(generateTypes(nullable())).toBe('null') | ||
| expect(generateTypes(notDefined())).toBe('undefined') | ||
| expect(generateTypes(any())).toBe('any') | ||
| }) | ||
| it('emits arrays with parentheses when the item is a union or intersection', () => { | ||
| expect(generateTypes(array(number()))).toBe('number[]') | ||
| expect(generateTypes(array(union([number(), string()])))).toBe('(number | string)[]') | ||
| expect(generateTypes(array(intersection([object({ a: number() }), object({ b: string() })])))).toBe( | ||
| '({\n a: number;\n} & {\n b: string;\n})[]', | ||
| ) | ||
| }) | ||
| it('emits records and objects', () => { | ||
| expect(generateTypes(record(string(), number()))).toBe('Record<string, number>') | ||
| expect( | ||
| generateTypes( | ||
| object({ | ||
| id: number(), | ||
| name: string(), | ||
| }), | ||
| ), | ||
| ).toBe('{\n id: number;\n name: string;\n}') | ||
| expect(generateTypes(object({}))).toBe('{}') | ||
| }) | ||
| it('indents nested object properties', () => { | ||
| const car = object({ | ||
| make: string(), | ||
| model: string(), | ||
| year: number(), | ||
| test: object({ test: string() }), | ||
| }) | ||
| expect(generateTypes(car)).toBe( | ||
| '{\n make: string;\n model: string;\n year: number;\n test: {\n test: string;\n };\n}', | ||
| ) | ||
| }) | ||
| it('extracts typeName into a single export and references by name', () => { | ||
| const user = object( | ||
| { | ||
| id: number(), | ||
| name: string(), | ||
| }, | ||
| { typeName: 'User' }, | ||
| ) | ||
| expect(generateTypes(user, { generatedAt: fixedGeneratedAt })).toBe( | ||
| `${autogeneratedBanner}export type User = {\n id: number;\n name: string;\n}`, | ||
| ) | ||
| }) | ||
| it('does not duplicate named types when the same name appears multiple times', () => { | ||
| const user = object({ id: number() }, { typeName: 'User' }) | ||
| const doc = object( | ||
| { | ||
| author: user, | ||
| editor: user, | ||
| }, | ||
| { typeName: 'Document' }, | ||
| ) | ||
| const out = generateTypes(doc, { generatedAt: fixedGeneratedAt }) | ||
| expect(out.match(/export type User/g)?.length).toBe(1) | ||
| expect(out).toContain('author: User') | ||
| expect(out).toContain('editor: User') | ||
| expect(out).toContain('export type Document =') | ||
| }) | ||
| it('includes typeComment as JSDoc on named exports', () => { | ||
| const t = object({}, { typeName: 'Empty', typeComment: 'No fields.' }) | ||
| expect(generateTypes(t, { generatedAt: fixedGeneratedAt })).toBe( | ||
| `${autogeneratedBanner}/** No fields. */\nexport type Empty = {}`, | ||
| ) | ||
| }) | ||
| it('formats multi-line typeComment as a JSDoc block', () => { | ||
| const t = object({}, { typeName: 'T', typeComment: 'Line 1\nLine 2' }) | ||
| expect(generateTypes(t, { generatedAt: fixedGeneratedAt })).toBe( | ||
| `${autogeneratedBanner}/** \n * Line 1\n * Line 2\n */\nexport type T = {}`, | ||
| ) | ||
| }) | ||
| it('prefixes file-style output with an autogenerated banner and timestamp', () => { | ||
| const t = object({ x: number() }, { typeName: 'T' }) | ||
| const out = generateTypes(t, { generatedAt: fixedGeneratedAt }) | ||
| expect(out.startsWith(autogeneratedBanner)).toBe(true) | ||
| expect(out).toContain('Generated at: 2000-01-01T00:00:00.000Z') | ||
| }) | ||
| it('wraps named types in export namespace when namespace option is set', () => { | ||
| const user = object( | ||
| { | ||
| id: number(), | ||
| name: string(), | ||
| }, | ||
| { typeName: 'User' }, | ||
| ) | ||
| expect(generateTypes(user, { generatedAt: fixedGeneratedAt, namespace: 'Models' })).toBe( | ||
| `${autogeneratedBanner}export namespace Models {\n export type User = {\n id: number;\n name: string;\n }\n}\n`, | ||
| ) | ||
| }) | ||
| it('keeps cross-references unqualified inside a namespace', () => { | ||
| const user = object({ id: number() }, { typeName: 'User' }) | ||
| const doc = object({ author: user }, { typeName: 'Document' }) | ||
| const out = generateTypes(doc, { generatedAt: fixedGeneratedAt, namespace: 'Api' }) | ||
| expect(out).toContain('export namespace Api {') | ||
| expect(out).toContain('author: User') | ||
| expect(out.match(/export type User/g)?.length).toBe(1) | ||
| }) | ||
| it('ignores namespace when it is not a valid TypeScript identifier', () => { | ||
| const t = object({ x: number() }, { typeName: 'T' }) | ||
| const out = generateTypes(t, { generatedAt: fixedGeneratedAt, namespace: 'not-valid' }) | ||
| expect(out).not.toContain('export namespace') | ||
| expect(out).toContain('export type T =') | ||
| }) | ||
| it('includes typeComment as JSDoc on object properties', () => { | ||
| const t = object({ | ||
| id: number({ typeComment: 'Unique id.' }), | ||
| name: string({ typeComment: 'Display name.' }), | ||
| }) | ||
| expect(generateTypes(t)).toBe('{\n /** Unique id. */\n id: number;\n /** Display name. */\n name: string;\n}') | ||
| }) | ||
| it('indents multi-line property typeComment in nested objects', () => { | ||
| const t = object({ | ||
| outer: string({ typeComment: 'A\nB' }), | ||
| inner: object({ | ||
| x: number({ typeComment: 'Nested.' }), | ||
| }), | ||
| }) | ||
| expect(generateTypes(t)).toBe( | ||
| '{\n /** \n * A\n * B\n */\n outer: string;\n inner: {\n /** Nested. */\n x: number;\n };\n}', | ||
| ) | ||
| }) | ||
| it('ignores invalid typeName and keeps inline shape', () => { | ||
| const bad = object({ x: number() }, { typeName: 'not-valid' }) | ||
| expect(generateTypes(bad)).toBe('{\n x: number;\n}') | ||
| }) | ||
| it('quotes object keys that are not valid identifiers', () => { | ||
| expect(generateTypes(object({ 'foo-bar': string() }))).toBe('{\n "foo-bar": string;\n}') | ||
| }) | ||
| it('emits unions and optionals', () => { | ||
| expect(generateTypes(union([literal(1), literal(2)]))).toBe('1 | 2') | ||
| expect(generateTypes(optional(number()))).toBe('number | undefined') | ||
| }) | ||
| it('emits never for an empty union and unknown for an empty intersection', () => { | ||
| expect(generateTypes(union([]))).toBe('never') | ||
| expect(generateTypes(intersection([]))).toBe('unknown') | ||
| }) | ||
| it('emits optional object properties with ? instead of | undefined', () => { | ||
| expect( | ||
| generateTypes( | ||
| object({ | ||
| id: number(), | ||
| name: optional(string()), | ||
| }), | ||
| ), | ||
| ).toBe('{\n id: number;\n name?: string;\n}') | ||
| }) | ||
| it('emits literal bigint', () => { | ||
| expect(generateTypes(literal(10n))).toBe('10n') | ||
| }) | ||
| it('unwraps evaluate to the inner schema type', () => { | ||
| expect(generateTypes(evaluate((v) => v, number()))).toBe('number') | ||
| }) | ||
| it('resolves lazy schemas', () => { | ||
| const schema = lazy(() => object({ n: number() })) | ||
| expect(generateTypes(schema)).toBe('{\n n: number;\n}') | ||
| }) | ||
| it('returns any when max depth is exhausted', () => { | ||
| expect(generateTypes(number(), { maxDepth: 0 })).toBe('any') | ||
| }) | ||
| }) |
-268
| import type { Schema } from './schema' | ||
| const DEFAULT_MAX_DEPTH = 10 | ||
| export type GenerateTypesOptions = { | ||
| maxDepth?: number | ||
| /** | ||
| * ISO 8601 timestamp printed in the autogenerated file banner. | ||
| * Defaults to the time of the `generateTypes` call when the banner is emitted. | ||
| */ | ||
| generatedAt?: string | ||
| /** | ||
| * When set to a valid TypeScript identifier, wraps all emitted `export type` declarations (and any | ||
| * trailing root type) in `export namespace Name { ... }` so consumers reference `Name.SomeType`. | ||
| */ | ||
| namespace?: string | ||
| } | ||
| type NamedDeclaration = { | ||
| name: string | ||
| body: string | ||
| comment?: string | ||
| } | ||
| type TypeGenContext = { | ||
| definitions: Map<string, string> | ||
| declarations: NamedDeclaration[] | ||
| inProgress: Set<string> | ||
| } | ||
| /** | ||
| * Returns TypeScript for the schema: named `typeName` nodes become `export type` aliases (once each), | ||
| * referenced by name elsewhere. With no named nodes, returns a single inline type expression (same as before). | ||
| * | ||
| * When at least one named type is emitted, the result is prefixed with a banner stating the output is | ||
| * autogenerated and must not be edited manually, plus a generation timestamp. | ||
| * | ||
| * Pass `namespace` to wrap declarations in `export namespace … { … }`. | ||
| */ | ||
| export const generateTypes = (schema: Schema, options?: GenerateTypesOptions): string => { | ||
| const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH | ||
| const ctx: TypeGenContext = { | ||
| definitions: new Map(), | ||
| declarations: [], | ||
| inProgress: new Set(), | ||
| } | ||
| const root = emitSchema(schema, maxDepth, ctx, '') | ||
| if (ctx.declarations.length === 0) { | ||
| return root | ||
| } | ||
| const declStrings = ctx.declarations.map(formatNamedDeclaration) | ||
| const body = declStrings.join('\n\n') | ||
| const lastDeclared = ctx.declarations.at(-1)?.name | ||
| let content = lastDeclared === root ? body : `${body}\n\n${root}` | ||
| const ns = options?.namespace | ||
| if (ns && isValidTypeScriptIdentifier(ns)) { | ||
| content = wrapDeclarationsInNamespace(ns, content) | ||
| } | ||
| const generatedAt = options?.generatedAt ?? new Date().toISOString() | ||
| return `${formatAutogeneratedFileBanner(generatedAt)}${content}` | ||
| } | ||
| const formatAutogeneratedFileBanner = (generatedAt: string): string => | ||
| `/**\n * This file is autogenerated. Do not edit it.\n *\n * Generated at: ${generatedAt}\n */\n\n` | ||
| const wrapDeclarationsInNamespace = (namespace: string, content: string): string => { | ||
| const body = content | ||
| .split('\n') | ||
| .map((line) => (line === '' ? '' : ` ${line}`)) | ||
| .join('\n') | ||
| return `export namespace ${namespace} {\n${body}\n}\n` | ||
| } | ||
| const formatNamedDeclaration = (d: NamedDeclaration): string => { | ||
| const commentBlock = d.comment ? formatTypeCommentAsJsDoc(d.comment) : '' | ||
| const comment = commentBlock ? `${commentBlock}\n` : '' | ||
| return `${comment}export type ${d.name} = ${d.body}` | ||
| } | ||
| /** Inline JSDoc for one line; multi-line comments use a newline after the opening delimiter and ` * ` on each line. */ | ||
| const formatTypeCommentAsJsDoc = (comment: string): string => { | ||
| const lines = comment.split(/\r?\n/) | ||
| const trimmed = lines.map((line) => line.trim()) | ||
| while (trimmed.length > 0 && trimmed[0] === '') { | ||
| trimmed.shift() | ||
| } | ||
| while (trimmed.length > 0 && trimmed.at(-1) === '') { | ||
| trimmed.pop() | ||
| } | ||
| if (trimmed.length === 0) { | ||
| return '' | ||
| } | ||
| if (trimmed.length === 1) { | ||
| return `/** ${trimmed[0]} */` | ||
| } | ||
| const body = trimmed.map((line) => ` * ${line}`).join('\n') | ||
| return `/** \n${body}\n */` | ||
| } | ||
| /** Prefixes each line of a JSDoc block with `indent` for object property comments. */ | ||
| const formatTypeCommentAsIndentedJsDoc = (indent: string, comment: string): string => { | ||
| const doc = formatTypeCommentAsJsDoc(comment) | ||
| if (!doc) { | ||
| return '' | ||
| } | ||
| return doc | ||
| .split('\n') | ||
| .map((line) => `${indent}${line}`) | ||
| .join('\n') | ||
| } | ||
| const getTypeName = (schema: Schema): string | undefined => { | ||
| if (schema.type === 'lazy' || schema.type === 'evaluate') { | ||
| return undefined | ||
| } | ||
| const name = schema.typeName | ||
| if (!name || !isValidTypeScriptIdentifier(name)) { | ||
| return undefined | ||
| } | ||
| return name | ||
| } | ||
| const getTypeComment = (schema: Schema): string | undefined => { | ||
| if (schema.type === 'lazy' || schema.type === 'evaluate') { | ||
| return undefined | ||
| } | ||
| if (schema.type === 'optional') { | ||
| return schema.typeComment ?? getTypeComment(schema.schema) | ||
| } | ||
| return schema.typeComment | ||
| } | ||
| const isValidTypeScriptIdentifier = (name: string): boolean => /^[$_A-Za-z][$_\w]*$/.test(name) | ||
| const emitSchema = (schema: Schema, depth: number, ctx: TypeGenContext, braceIndent: string): string => { | ||
| const name = getTypeName(schema) | ||
| if (name) { | ||
| if (ctx.definitions.has(name)) { | ||
| return name | ||
| } | ||
| if (ctx.inProgress.has(name)) { | ||
| return name | ||
| } | ||
| ctx.inProgress.add(name) | ||
| const body = structuralEmit(schema, depth, ctx, '') | ||
| ctx.inProgress.delete(name) | ||
| if (!ctx.definitions.has(name)) { | ||
| ctx.definitions.set(name, body) | ||
| ctx.declarations.push({ | ||
| name, | ||
| body, | ||
| comment: getTypeComment(schema), | ||
| }) | ||
| } | ||
| return name | ||
| } | ||
| return structuralEmit(schema, depth, ctx, braceIndent) | ||
| } | ||
| const structuralEmit = (schema: Schema, depth: number, ctx: TypeGenContext, braceIndent: string): string => { | ||
| if (depth <= 0) { | ||
| return 'any' | ||
| } | ||
| const next = depth - 1 | ||
| switch (schema.type) { | ||
| case 'number': | ||
| return 'number' | ||
| case 'string': | ||
| return 'string' | ||
| case 'boolean': | ||
| return 'boolean' | ||
| case 'nullable': | ||
| return 'null' | ||
| case 'notDefined': | ||
| return 'undefined' | ||
| case 'any': | ||
| return 'any' | ||
| case 'array': { | ||
| const item = emitSchema(schema.items, next, ctx, braceIndent) | ||
| return needsArrayItemParen(item) ? `(${item})[]` : `${item}[]` | ||
| } | ||
| case 'record': { | ||
| const key = emitSchema(schema.key, next, ctx, braceIndent) | ||
| const value = emitSchema(schema.value, next, ctx, braceIndent) | ||
| return `Record<${key}, ${value}>` | ||
| } | ||
| case 'object': { | ||
| const entries = Object.entries(schema.properties) | ||
| if (entries.length === 0) { | ||
| return '{}' | ||
| } | ||
| const keyIndent = `${braceIndent} ` | ||
| const props = entries.map(([key, child]) => { | ||
| const tsKey = /^[$_a-zA-Z][$_\w]*$/.test(key) ? key : JSON.stringify(key) | ||
| const optionalProp = child.type === 'optional' | ||
| const valueSchema = optionalProp ? child.schema : child | ||
| const value = emitSchema(valueSchema, next, ctx, keyIndent) | ||
| const propComment = getTypeComment(child) | ||
| const docBlock = propComment ? formatTypeCommentAsIndentedJsDoc(keyIndent, propComment) : '' | ||
| const propLine = optionalProp ? `${keyIndent}${tsKey}?: ${value};` : `${keyIndent}${tsKey}: ${value};` | ||
| return docBlock ? `${docBlock}\n${propLine}` : propLine | ||
| }) | ||
| return `{\n${props.join('\n')}\n${braceIndent}}` | ||
| } | ||
| case 'optional': { | ||
| const inner = emitSchema(schema.schema, next, ctx, braceIndent) | ||
| return `${wrapUnionMember(inner)} | undefined` | ||
| } | ||
| case 'union': | ||
| if (schema.schemas.length === 0) { | ||
| return 'never' | ||
| } | ||
| return schema.schemas.map((s) => wrapUnionMember(emitSchema(s, next, ctx, braceIndent))).join(' | ') | ||
| case 'intersection': | ||
| if (schema.schemas.length === 0) { | ||
| return 'unknown' | ||
| } | ||
| return schema.schemas.map((s) => wrapIntersectionMember(emitSchema(s, next, ctx, braceIndent))).join(' & ') | ||
| case 'literal': | ||
| return literalToTs(schema.value) | ||
| case 'lazy': | ||
| return emitSchema(schema.schema(), next, ctx, braceIndent) | ||
| case 'evaluate': | ||
| return emitSchema(schema.schema, next, ctx, braceIndent) | ||
| default: { | ||
| const _exhaustive: never = schema | ||
| return _exhaustive | ||
| } | ||
| } | ||
| } | ||
| const literalToTs = (value: string | number | boolean | bigint): string => { | ||
| if (typeof value === 'bigint') { | ||
| return `${value}n` | ||
| } | ||
| return JSON.stringify(value) | ||
| } | ||
| const needsArrayItemParen = (t: string): boolean => { | ||
| if (t === 'number' || t === 'string' || t === 'boolean' || t === 'null' || t === 'undefined' || t === 'any') { | ||
| return false | ||
| } | ||
| // Union: `A | B[]` is `A | (B[])`; intersection: `A & B[]` is `A & (B[])`. Wrap the whole item type. | ||
| return t.includes(' | ') || t.includes(' & ') | ||
| } | ||
| const wrapUnionMember = (t: string): string => { | ||
| if (t === 'number' || t === 'string' || t === 'boolean' || t === 'null' || t === 'undefined' || t === 'any') { | ||
| return t | ||
| } | ||
| if (/^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?|-?\d+n|"(?:[^"\\]|\\.)*"|true|false)$/.test(t)) { | ||
| return t | ||
| } | ||
| if (/^[$_A-Za-z][$_\w]*$/.test(t)) { | ||
| return t | ||
| } | ||
| return `(${t})` | ||
| } | ||
| const wrapIntersectionMember = (t: string): string => { | ||
| if (t.includes(' | ') || t.includes(' & ')) { | ||
| return `(${t})` | ||
| } | ||
| return t | ||
| } |
-110
| import type { | ||
| AnySchema, | ||
| ArraySchema, | ||
| BooleanSchema, | ||
| EvaluateSchema, | ||
| IntersectionSchema, | ||
| LazySchema, | ||
| LiteralSchema, | ||
| NotDefinedSchema, | ||
| NullableSchema, | ||
| NumberSchema, | ||
| ObjectSchema, | ||
| OptionalSchema, | ||
| RecordSchema, | ||
| Schema, | ||
| StringSchema, | ||
| UnionSchema, | ||
| } from './schema' | ||
| // Export Static type with depth limit to prevent infinite recursion | ||
| export type Static<T> = _Static<T, 10> | ||
| /** | ||
| * Folds intersection member schemas into an intersection of their static types. | ||
| * Uses `Schema` for tuple positions (not a narrower alias) so `infer First extends …` does not | ||
| * reject valid tuple elements and collapse to `{}`. | ||
| */ | ||
| type IntersectObjectStatics<Schemas extends readonly Schema[], Depth extends number> = Schemas extends readonly [] | ||
| ? {} | ||
| : Schemas extends readonly [infer First extends Schema, ...infer Rest extends readonly Schema[]] | ||
| ? _Static<First, Depth> & IntersectObjectStatics<Rest, Depth> | ||
| : {} | ||
| type OptionalPropertyKeys<P> = { | ||
| [K in keyof P]: P[K] extends OptionalSchema<any> ? K : never | ||
| }[keyof P] | ||
| type RequiredPropertyKeys<P> = { | ||
| [K in keyof P]: P[K] extends OptionalSchema<any> ? never : K | ||
| }[keyof P] | ||
| type OptionalSchemaInner<S> = S extends OptionalSchema<infer Inner> ? Inner : never | ||
| type ObjectStatics<Properties, Depth extends number> = [keyof Properties] extends [never] | ||
| ? {} | ||
| : OptionalPropertyKeys<Properties> extends never | ||
| ? { [K in keyof Properties]: _Static<Properties[K], Prev<Depth>> } | ||
| : RequiredPropertyKeys<Properties> extends never | ||
| ? { [K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>> } | ||
| : { [K in RequiredPropertyKeys<Properties>]: _Static<Properties[K], Prev<Depth>> } & { | ||
| [K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>> | ||
| } | ||
| // Internal type with depth counter | ||
| type _Static<T, Depth extends number = 10> = Depth extends 0 | ||
| ? any | ||
| : T extends LiteralSchema<infer Value> | ||
| ? Value | ||
| : T extends NumberSchema | ||
| ? number | ||
| : T extends StringSchema | ||
| ? string | ||
| : T extends BooleanSchema | ||
| ? boolean | ||
| : T extends NullableSchema | ||
| ? null | ||
| : T extends NotDefinedSchema | ||
| ? undefined | ||
| : T extends AnySchema | ||
| ? any | ||
| : T extends ArraySchema<infer Item> | ||
| ? Array<_Static<Item, Prev<Depth>>> | ||
| : T extends RecordSchema<infer Key, infer Value> | ||
| ? Record<_Static<Key, Prev<Depth>> & PropertyKey, _Static<Value, Prev<Depth>>> | ||
| : T extends ObjectSchema<infer Properties> | ||
| ? ObjectStatics<Properties, Depth> | ||
| : T extends OptionalSchema<infer S> | ||
| ? _Static<S, Prev<Depth>> | undefined | ||
| : T extends IntersectionSchema<infer Schemas> | ||
| ? IntersectObjectStatics<Schemas, Prev<Depth>> | ||
| : T extends UnionSchema<infer Schemas> | ||
| ? _Static<Schemas[number], Prev<Depth>> | ||
| : T extends EvaluateSchema<infer S> | ||
| ? _Static<S, Prev<Depth>> | ||
| : T extends LazySchema<infer S> | ||
| ? _Static<ReturnType<S>, Prev<Depth>> | ||
| : never | ||
| // Helper type to decrement depth counter | ||
| type Prev<T extends number> = T extends 10 | ||
| ? 9 | ||
| : T extends 9 | ||
| ? 8 | ||
| : T extends 8 | ||
| ? 7 | ||
| : T extends 7 | ||
| ? 6 | ||
| : T extends 6 | ||
| ? 5 | ||
| : T extends 5 | ||
| ? 4 | ||
| : T extends 4 | ||
| ? 3 | ||
| : T extends 3 | ||
| ? 2 | ||
| : T extends 2 | ||
| ? 1 | ||
| : T extends 1 | ||
| ? 0 | ||
| : 0 |
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| any, | ||
| array, | ||
| boolean, | ||
| evaluate, | ||
| intersection, | ||
| lazy, | ||
| literal, | ||
| notDefined, | ||
| nullable, | ||
| number, | ||
| object, | ||
| optional, | ||
| record, | ||
| string, | ||
| union, | ||
| } from '@/schema' | ||
| import { validate } from '@/validate' | ||
| describe('any', () => { | ||
| const T = any() | ||
| it('Should pass string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| }) | ||
| describe('array', () => { | ||
| it('Should pass number array', () => { | ||
| const T = array(number()) | ||
| const value = [1, 2, 3] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail number array', () => { | ||
| const T = array(number()) | ||
| const value = ['a', 'b', 'c'] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass object array', () => { | ||
| const T = array(object({ x: number() })) | ||
| const value = [{ x: 1 }, { x: 1 }, { x: 1 }] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail object array', () => { | ||
| const T = array(object({ x: number() })) | ||
| const value = [{ x: 1 }, { x: 1 }, 1] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(array(any()), value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('boolean', () => { | ||
| const T = boolean() | ||
| it('Should fail string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('literal', () => { | ||
| const T = literal('hello') | ||
| it('Should pass literal', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail literal', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail literal with undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail literal with null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('null', () => { | ||
| const T = nullable() | ||
| it('Should fail string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('number', () => { | ||
| const T = number() | ||
| it('Should not validate NaN', () => { | ||
| const result = validate(T, Number.NaN) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should not validate +Infinity', () => { | ||
| const result = validate(T, Number.POSITIVE_INFINITY) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should not validate -Infinity', () => { | ||
| const result = validate(T, Number.NEGATIVE_INFINITY) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail NaN', () => { | ||
| const result = validate(T, Number.NaN) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('object', () => { | ||
| const T = object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| a: string(), | ||
| b: string(), | ||
| c: string(), | ||
| }) | ||
| it('Should pass object', () => { | ||
| const value = { | ||
| x: 1, | ||
| y: 1, | ||
| z: 1, | ||
| a: '1', | ||
| b: '1', | ||
| c: '1', | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail object with invalid property', () => { | ||
| const value = { | ||
| x: true, | ||
| y: 1, | ||
| z: 1, | ||
| a: '1', | ||
| b: '1', | ||
| c: '1', | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object with missing property', () => { | ||
| const value = { | ||
| y: 1, | ||
| z: 1, | ||
| a: '1', | ||
| b: '1', | ||
| c: '1', | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass object with optional properties', () => { | ||
| const T = object({ | ||
| x: optional(number()), | ||
| y: optional(number()), | ||
| z: optional(number()), | ||
| a: string(), | ||
| b: string(), | ||
| c: string(), | ||
| }) | ||
| const value = { | ||
| a: '1', | ||
| b: '1', | ||
| c: '1', | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail object with null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object with undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should check for property key if property type is undefined', () => { | ||
| const T = object({ x: notDefined() }) | ||
| expect(validate(T, { x: undefined })).toBe(true) | ||
| expect(validate(T, {})).toBe(true) | ||
| }) | ||
| it('Should check for property key if property type extends undefined', () => { | ||
| const T = object({ x: union([number(), notDefined()]) }) | ||
| expect(validate(T, { x: 1 })).toBe(true) | ||
| expect(validate(T, { x: undefined })).toBe(true) | ||
| expect(validate(T, {})).toBe(true) | ||
| }) | ||
| it('Should not check for property key if property type is undefined and optional', () => { | ||
| const T = object({ x: optional(notDefined()) }) | ||
| expect(validate(T, { x: undefined })).toBe(true) | ||
| expect(validate(T, {})).toBe(true) | ||
| }) | ||
| it('Should not check for property key if property type extends undefined and optional', () => { | ||
| const T = object({ x: optional(union([number(), notDefined()])) }) | ||
| expect(validate(T, { x: 1 })).toBe(true) | ||
| expect(validate(T, { x: undefined })).toBe(true) | ||
| expect(validate(T, {})).toBe(true) | ||
| }) | ||
| it('Should check undefined for optional property of number', () => { | ||
| const T = object({ x: optional(number()) }) | ||
| expect(validate(T, { x: 1 })).toBe(true) | ||
| expect(validate(T, { x: undefined })).toBe(true) // allowed by default | ||
| expect(validate(T, {})).toBe(true) | ||
| }) | ||
| it('Should check undefined for optional property of undefined', () => { | ||
| const T = object({ x: optional(notDefined()) }) | ||
| expect(validate(T, { x: 1 })).toBe(false) | ||
| expect(validate(T, {})).toBe(true) | ||
| expect(validate(T, { x: undefined })).toBe(true) | ||
| }) | ||
| }) | ||
| describe('record', () => { | ||
| it('Should pass record', () => { | ||
| const T = record( | ||
| string(), | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| }), | ||
| ) | ||
| const value = { | ||
| position: { | ||
| x: 1, | ||
| y: 2, | ||
| z: 3, | ||
| }, | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail record with Date', () => { | ||
| const T = record(string(), string()) | ||
| const result = validate(T, new Date()) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail record with Uint8Array', () => { | ||
| const T = record(string(), string()) | ||
| const result = validate(T, new Uint8Array()) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail record with missing property', () => { | ||
| const T = record( | ||
| string(), | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| }), | ||
| ) | ||
| const value = { | ||
| position: { | ||
| x: 1, | ||
| y: 2, | ||
| }, | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail record with invalid property', () => { | ||
| const T = record( | ||
| string(), | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: number(), | ||
| }), | ||
| ) | ||
| const value = { | ||
| position: { | ||
| x: 1, | ||
| y: 2, | ||
| z: '3', | ||
| }, | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass record with optional property', () => { | ||
| const T = record( | ||
| string(), | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: optional(number()), | ||
| }), | ||
| ) | ||
| const value = { | ||
| position: { | ||
| x: 1, | ||
| y: 2, | ||
| }, | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass record with optional property', () => { | ||
| const T = record( | ||
| string(), | ||
| object({ | ||
| x: number(), | ||
| y: number(), | ||
| z: optional(number()), | ||
| }), | ||
| ) | ||
| const value = { | ||
| position: { | ||
| x: 1, | ||
| y: 2, | ||
| }, | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should validate for any keys', () => { | ||
| const T = record(any(), nullable()) | ||
| const R = validate(T, { | ||
| a: null, | ||
| b: null, | ||
| 0: null, | ||
| 1: null, | ||
| }) | ||
| expect(R).toBe(true) | ||
| }) | ||
| // TODO: implement this | ||
| it.skip('Should pass record with number key', () => { | ||
| // @ts-expect-error - number key is not supported yet | ||
| const T = record(number(), string()) | ||
| const value = { | ||
| 0: 'a', | ||
| 1: 'a', | ||
| 2: 'a', | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it.skip('Should not pass record with invalid number key', () => { | ||
| // @ts-expect-error - number key is not supported yet | ||
| const T = record(number(), string()) | ||
| const value = { | ||
| a: 'a', | ||
| 1: 'a', | ||
| 2: 'a', | ||
| } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it.skip('Should validate for number keys', () => { | ||
| // @ts-expect-error - number key is not supported yet | ||
| const T = record(number(), nullable()) | ||
| const R1 = validate(T, { | ||
| a: null, | ||
| b: null, | ||
| 0: null, | ||
| 1: null, | ||
| }) | ||
| const R2 = validate(T, { | ||
| 0: null, | ||
| 1: null, | ||
| }) | ||
| expect(R1).toBe(false) | ||
| expect(R2).toBe(true) | ||
| }) | ||
| }) | ||
| describe('string', () => { | ||
| const T = string() | ||
| it('Should pass string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('union', () => { | ||
| const A = object({ | ||
| type: literal('A'), | ||
| x: number(), | ||
| y: number(), | ||
| }) | ||
| const B = object({ | ||
| type: literal('B'), | ||
| x: boolean(), | ||
| y: boolean(), | ||
| }) | ||
| const T = union([A, B]) | ||
| it('Should pass union A', () => { | ||
| const value = { type: 'A', x: 1, y: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should pass union B', () => { | ||
| const value = { type: 'B', x: true, y: false } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail union A', () => { | ||
| const value = { type: 'A', x: true, y: false } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail union B', () => { | ||
| const value = { type: 'B', x: 1, y: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass union A with optional properties', () => { | ||
| const A = object({ | ||
| type: literal('A'), | ||
| x: optional(number()), | ||
| y: optional(number()), | ||
| }) | ||
| const B = object({ | ||
| type: literal('B'), | ||
| x: boolean(), | ||
| y: boolean(), | ||
| }) | ||
| const T = union([A, B]) | ||
| const value = { type: 'A' } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail union A with invalid optional properties', () => { | ||
| const A = object({ | ||
| type: literal('A'), | ||
| x: optional(number()), | ||
| y: optional(number()), | ||
| }) | ||
| const B = object({ | ||
| type: literal('B'), | ||
| x: boolean(), | ||
| y: boolean(), | ||
| }) | ||
| const T = union([A, B]) | ||
| const value = { type: 'A', x: true, y: false } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('intersection', () => { | ||
| it('passes when the value satisfies every member object schema', () => { | ||
| const T = intersection([object({ a: number(), b: number() }), object({ c: string(), d: string() })]) | ||
| expect(validate(T, { a: 1, b: 2, c: 'x', d: 'y' })).toBe(true) | ||
| }) | ||
| it('fails when one member object schema fails', () => { | ||
| const T = intersection([object({ a: number(), b: number() }), object({ c: string(), d: string() })]) | ||
| expect(validate(T, { a: 1, b: 2, c: 'x', d: 3 })).toBe(false) | ||
| }) | ||
| it('fails when the first member fails even if later members would pass', () => { | ||
| const T = intersection([object({ x: literal(1) }), object({ y: string() })]) | ||
| expect(validate(T, { x: 2, y: 'ok' })).toBe(false) | ||
| }) | ||
| it('requires overlapping keys to satisfy every arm that declares them', () => { | ||
| const T = intersection([object({ id: number() }), object({ id: string() })]) | ||
| expect(validate(T, { id: 1 })).toBe(false) | ||
| expect(validate(T, { id: '1' })).toBe(false) | ||
| }) | ||
| it('rejects non-plain objects before member checks', () => { | ||
| const T = intersection([object({ x: number() }), object({ y: number() })]) | ||
| expect(validate(T, null)).toBe(false) | ||
| expect(validate(T, undefined)).toBe(false) | ||
| expect(validate(T, 0)).toBe(false) | ||
| expect(validate(T, [])).toBe(false) | ||
| expect(validate(T, new Date())).toBe(false) | ||
| }) | ||
| it('treats an empty intersection as vacuously valid', () => { | ||
| const T = intersection([]) | ||
| expect(validate(T, null)).toBe(true) | ||
| expect(validate(T, { a: 1 })).toBe(true) | ||
| }) | ||
| it('matches a single member the same as that object schema alone', () => { | ||
| const O = object({ x: number() }) | ||
| const T = intersection([O]) | ||
| expect(validate(T, { x: 1 })).toBe(true) | ||
| expect(validate(T, {})).toBe(false) | ||
| expect(validate(O, { x: 1 })).toBe(validate(T, { x: 1 })) | ||
| }) | ||
| it('validates members that use lazy schemas', () => { | ||
| const T = intersection([object({ a: number() }), object({ nested: lazy(() => object({ z: string() })) })]) | ||
| expect(validate(T, { a: 1, nested: { z: 'ok' } })).toBe(true) | ||
| expect(validate(T, { a: 1, nested: { z: 1 } })).toBe(false) | ||
| }) | ||
| }) | ||
| describe('notDefined', () => { | ||
| const T = notDefined() | ||
| it('Should fail string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should pass undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('evaluate', () => { | ||
| const T = evaluate((value) => value, number()) | ||
| it('Should pass number', () => { | ||
| const value = 1 | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail object', () => { | ||
| const value = { a: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) | ||
| describe('lazy', () => { | ||
| const T = lazy(() => object({ x: number() })) | ||
| it('Should pass object', () => { | ||
| const value = { x: 1 } | ||
| const result = validate(T, value) | ||
| expect(result).toBe(true) | ||
| }) | ||
| it('Should fail string', () => { | ||
| const value = 'hello' | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail boolean', () => { | ||
| const value = true | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail null', () => { | ||
| const value = null | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail undefined', () => { | ||
| const value = undefined | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail array', () => { | ||
| const value = [1, 2] | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| it('Should fail Date', () => { | ||
| const value = new Date() | ||
| const result = validate(T, value) | ||
| expect(result).toBe(false) | ||
| }) | ||
| }) |
-106
| import { isObject } from './helpers/is-object' | ||
| import type { Schema } from './schema' | ||
| /** | ||
| * Validates that a given value matches the specified schema. | ||
| * | ||
| * The schema describes the expected structure/type of data. | ||
| * Supported schema types include: | ||
| * - 'any': Accepts any value. | ||
| * - 'number': Only numbers are valid. | ||
| * - 'string': Only strings are valid. | ||
| * - 'boolean': Only booleans are valid. | ||
| * - 'nullable': Only `null` is valid. | ||
| * - 'notDefined': Only `undefined` is valid. | ||
| * - 'array': Array with all items validated recursively. | ||
| * - 'record': Object with string/number keys and values, checked recursively. | ||
| * - 'object': Plain object with fixed property keys, each validated recursively. | ||
| * - 'union': Accepts if value matches any of the listed schemas. | ||
| * - 'optional': Accepts `undefined` or a value matching the inner schema. | ||
| * - 'intersection': Accepts if value matches every member schema (members are object schemas; value must be a plain object). | ||
| * - 'literal': Exact match with a literal value. | ||
| * - 'lazy': Delegates to the schema returned by the factory. | ||
| * - 'evaluate': Transforms value then validates against an inner schema. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { number, object, string, validate } from '@scalar/validation' | ||
| * | ||
| * const schema = object({ id: number(), name: string() }) | ||
| * validate(schema, { id: 1, name: 'Ada' }) // true | ||
| * validate(schema, { id: 1, name: 2 }) // false | ||
| * ``` | ||
| * | ||
| * If schema is `undefined`, validation fails. | ||
| * Returns true if the value matches the schema, false otherwise. | ||
| */ | ||
| export const validate = (schema: Schema | undefined, value: unknown): boolean => { | ||
| if (!schema) { | ||
| return false | ||
| } | ||
| if (schema.type === 'any') { | ||
| return true | ||
| } | ||
| if (schema.type === 'number') { | ||
| return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value) | ||
| } | ||
| if (schema.type === 'string') { | ||
| return typeof value === 'string' | ||
| } | ||
| if (schema.type === 'boolean') { | ||
| return typeof value === 'boolean' | ||
| } | ||
| if (schema.type === 'nullable') { | ||
| return value === null | ||
| } | ||
| if (schema.type === 'notDefined') { | ||
| return value === undefined | ||
| } | ||
| if (schema.type === 'array') { | ||
| return Array.isArray(value) && value.every((item) => validate(schema.items, item)) | ||
| } | ||
| if (schema.type === 'record') { | ||
| if (!isObject(value)) { | ||
| return false | ||
| } | ||
| const keys = Object.keys(value) | ||
| return keys.every((key) => validate(schema.key, key) && validate(schema.value, value[key])) | ||
| } | ||
| if (schema.type === 'object') { | ||
| if (!isObject(value)) { | ||
| return false | ||
| } | ||
| const schemaKeys = Object.keys(schema.properties) | ||
| return schemaKeys.every((key) => validate(schema.properties[key], value[key])) | ||
| } | ||
| if (schema.type === 'optional') { | ||
| return value === undefined || validate(schema.schema, value) | ||
| } | ||
| if (schema.type === 'union') { | ||
| return schema.schemas.some((schema) => validate(schema, value)) | ||
| } | ||
| if (schema.type === 'intersection') { | ||
| if (schema.schemas.length === 0) { | ||
| // Vacuous: no constraints (matches `Array.prototype.every` on an empty list). | ||
| return true | ||
| } | ||
| if (!isObject(value)) { | ||
| return false | ||
| } | ||
| return schema.schemas.every((subSchema) => validate(subSchema, value)) | ||
| } | ||
| if (schema.type === 'literal') { | ||
| return value === schema.value | ||
| } | ||
| if (schema.type === 'lazy') { | ||
| return validate(schema.schema(), value) | ||
| } | ||
| if (schema.type === 'evaluate') { | ||
| return validate(schema.schema, schema.expression(value)) | ||
| } | ||
| // We need to assert here that schema has the type never so we know we handle all cases | ||
| const _exhaustive: never = schema | ||
| console.warn('Unknown schema type:', _exhaustive) | ||
| return false | ||
| } |
| { | ||
| "extends": "./tsconfig.json", | ||
| "include": ["src"], | ||
| "exclude": ["**/*.test.ts", "vite.config.ts"], | ||
| "compilerOptions": { | ||
| "outDir": "dist", | ||
| "declaration": true, | ||
| "declarationMap": true, | ||
| "noEmit": false | ||
| } | ||
| } |
| { | ||
| "extends": "../../tsconfig.json", | ||
| "compilerOptions": { | ||
| "paths": { | ||
| "@/*": ["./src/*"], | ||
| "@test/*": ["./test/*"] | ||
| } | ||
| }, | ||
| "include": ["src/**/*.ts", "playground/**/*.ts", "test/**/*.ts", "esbuild.ts", "vite.config.ts"] | ||
| } |
| import { resolve } from 'node:path' | ||
| import { defineConfig } from 'vite' | ||
| export default defineConfig({ | ||
| plugins: [], | ||
| resolve: { | ||
| alias: { | ||
| '@': resolve(import.meta.dirname, './src'), | ||
| '@test': resolve(import.meta.dirname, './test'), | ||
| }, | ||
| }, | ||
| }) |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
0
-100%54707
-63.64%25
-37.5%891
-78.39%1
Infinity%