Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@scalar/validation

Package Overview
Dependencies
Maintainers
8
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@scalar/validation - npm Package Compare versions

Comparing version
0.3.0
to
0.3.1
+6
-0
CHANGELOG.md
# @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
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)
})
})
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
}
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'
/**
* 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')
})
})
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
}
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)
})
})
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'),
},
},
})