@scalar/validation 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ any,
5
+ array,
6
+ boolean,
7
+ evaluate,
8
+ lazy,
9
+ intersection,
10
+ literal,
11
+ notDefined,
12
+ nullable,
13
+ number,
14
+ object,
15
+ optional,
16
+ record,
17
+ string,
18
+ union,
19
+ } from '@/schema'
20
+ import { generateTypes } from '@/typegen'
21
+
22
+ const fixedGeneratedAt = '2000-01-01T00:00:00.000Z'
23
+
24
+ const autogeneratedBanner = `/**\n * This file is autogenerated. Do not edit it.\n *\n * Generated at: ${fixedGeneratedAt}\n */\n\n`
25
+
26
+ describe('typegen', () => {
27
+ it('emits primitives and special scalars', () => {
28
+ expect(generateTypes(number())).toBe('number')
29
+ expect(generateTypes(string())).toBe('string')
30
+ expect(generateTypes(boolean())).toBe('boolean')
31
+ expect(generateTypes(nullable())).toBe('null')
32
+ expect(generateTypes(notDefined())).toBe('undefined')
33
+ expect(generateTypes(any())).toBe('any')
34
+ })
35
+
36
+ it('emits arrays with parentheses when the item is a union or intersection', () => {
37
+ expect(generateTypes(array(number()))).toBe('number[]')
38
+ expect(generateTypes(array(union([number(), string()])))).toBe('(number | string)[]')
39
+ expect(generateTypes(array(intersection([object({ a: number() }), object({ b: string() })])))).toBe(
40
+ '({\n a: number;\n} & {\n b: string;\n})[]',
41
+ )
42
+ })
43
+
44
+ it('emits records and objects', () => {
45
+ expect(generateTypes(record(string(), number()))).toBe('Record<string, number>')
46
+ expect(
47
+ generateTypes(
48
+ object({
49
+ id: number(),
50
+ name: string(),
51
+ }),
52
+ ),
53
+ ).toBe('{\n id: number;\n name: string;\n}')
54
+ expect(generateTypes(object({}))).toBe('{}')
55
+ })
56
+
57
+ it('indents nested object properties', () => {
58
+ const car = object({
59
+ make: string(),
60
+ model: string(),
61
+ year: number(),
62
+ test: object({ test: string() }),
63
+ })
64
+ expect(generateTypes(car)).toBe(
65
+ '{\n make: string;\n model: string;\n year: number;\n test: {\n test: string;\n };\n}',
66
+ )
67
+ })
68
+
69
+ it('extracts typeName into a single export and references by name', () => {
70
+ const user = object(
71
+ {
72
+ id: number(),
73
+ name: string(),
74
+ },
75
+ { typeName: 'User' },
76
+ )
77
+ expect(generateTypes(user, { generatedAt: fixedGeneratedAt })).toBe(
78
+ `${autogeneratedBanner}export type User = {\n id: number;\n name: string;\n}`,
79
+ )
80
+ })
81
+
82
+ it('does not duplicate named types when the same name appears multiple times', () => {
83
+ const user = object({ id: number() }, { typeName: 'User' })
84
+ const doc = object(
85
+ {
86
+ author: user,
87
+ editor: user,
88
+ },
89
+ { typeName: 'Document' },
90
+ )
91
+ const out = generateTypes(doc, { generatedAt: fixedGeneratedAt })
92
+ expect(out.match(/export type User/g)?.length).toBe(1)
93
+ expect(out).toContain('author: User')
94
+ expect(out).toContain('editor: User')
95
+ expect(out).toContain('export type Document =')
96
+ })
97
+
98
+ it('includes typeComment as JSDoc on named exports', () => {
99
+ const t = object({}, { typeName: 'Empty', typeComment: 'No fields.' })
100
+ expect(generateTypes(t, { generatedAt: fixedGeneratedAt })).toBe(
101
+ `${autogeneratedBanner}/** No fields. */\nexport type Empty = {}`,
102
+ )
103
+ })
104
+
105
+ it('formats multi-line typeComment as a JSDoc block', () => {
106
+ const t = object({}, { typeName: 'T', typeComment: 'Line 1\nLine 2' })
107
+ expect(generateTypes(t, { generatedAt: fixedGeneratedAt })).toBe(
108
+ `${autogeneratedBanner}/** \n * Line 1\n * Line 2\n */\nexport type T = {}`,
109
+ )
110
+ })
111
+
112
+ it('prefixes file-style output with an autogenerated banner and timestamp', () => {
113
+ const t = object({ x: number() }, { typeName: 'T' })
114
+ const out = generateTypes(t, { generatedAt: fixedGeneratedAt })
115
+ expect(out.startsWith(autogeneratedBanner)).toBe(true)
116
+ expect(out).toContain('Generated at: 2000-01-01T00:00:00.000Z')
117
+ })
118
+
119
+ it('wraps named types in export namespace when namespace option is set', () => {
120
+ const user = object(
121
+ {
122
+ id: number(),
123
+ name: string(),
124
+ },
125
+ { typeName: 'User' },
126
+ )
127
+ expect(generateTypes(user, { generatedAt: fixedGeneratedAt, namespace: 'Models' })).toBe(
128
+ `${autogeneratedBanner}export namespace Models {\n export type User = {\n id: number;\n name: string;\n }\n}\n`,
129
+ )
130
+ })
131
+
132
+ it('keeps cross-references unqualified inside a namespace', () => {
133
+ const user = object({ id: number() }, { typeName: 'User' })
134
+ const doc = object({ author: user }, { typeName: 'Document' })
135
+ const out = generateTypes(doc, { generatedAt: fixedGeneratedAt, namespace: 'Api' })
136
+ expect(out).toContain('export namespace Api {')
137
+ expect(out).toContain('author: User')
138
+ expect(out.match(/export type User/g)?.length).toBe(1)
139
+ })
140
+
141
+ it('ignores namespace when it is not a valid TypeScript identifier', () => {
142
+ const t = object({ x: number() }, { typeName: 'T' })
143
+ const out = generateTypes(t, { generatedAt: fixedGeneratedAt, namespace: 'not-valid' })
144
+ expect(out).not.toContain('export namespace')
145
+ expect(out).toContain('export type T =')
146
+ })
147
+
148
+ it('includes typeComment as JSDoc on object properties', () => {
149
+ const t = object({
150
+ id: number({ typeComment: 'Unique id.' }),
151
+ name: string({ typeComment: 'Display name.' }),
152
+ })
153
+ expect(generateTypes(t)).toBe('{\n /** Unique id. */\n id: number;\n /** Display name. */\n name: string;\n}')
154
+ })
155
+
156
+ it('indents multi-line property typeComment in nested objects', () => {
157
+ const t = object({
158
+ outer: string({ typeComment: 'A\nB' }),
159
+ inner: object({
160
+ x: number({ typeComment: 'Nested.' }),
161
+ }),
162
+ })
163
+ expect(generateTypes(t)).toBe(
164
+ '{\n /** \n * A\n * B\n */\n outer: string;\n inner: {\n /** Nested. */\n x: number;\n };\n}',
165
+ )
166
+ })
167
+
168
+ it('ignores invalid typeName and keeps inline shape', () => {
169
+ const bad = object({ x: number() }, { typeName: 'not-valid' })
170
+ expect(generateTypes(bad)).toBe('{\n x: number;\n}')
171
+ })
172
+
173
+ it('quotes object keys that are not valid identifiers', () => {
174
+ expect(generateTypes(object({ 'foo-bar': string() }))).toBe('{\n "foo-bar": string;\n}')
175
+ })
176
+
177
+ it('emits unions and optionals', () => {
178
+ expect(generateTypes(union([literal(1), literal(2)]))).toBe('1 | 2')
179
+ expect(generateTypes(optional(number()))).toBe('number | undefined')
180
+ })
181
+
182
+ it('emits never for an empty union and unknown for an empty intersection', () => {
183
+ expect(generateTypes(union([]))).toBe('never')
184
+ expect(generateTypes(intersection([]))).toBe('unknown')
185
+ })
186
+
187
+ it('emits optional object properties with ? instead of | undefined', () => {
188
+ expect(
189
+ generateTypes(
190
+ object({
191
+ id: number(),
192
+ name: optional(string()),
193
+ }),
194
+ ),
195
+ ).toBe('{\n id: number;\n name?: string;\n}')
196
+ })
197
+
198
+ it('emits literal bigint', () => {
199
+ expect(generateTypes(literal(10n))).toBe('10n')
200
+ })
201
+
202
+ it('unwraps evaluate to the inner schema type', () => {
203
+ expect(generateTypes(evaluate((v) => v, number()))).toBe('number')
204
+ })
205
+
206
+ it('resolves lazy schemas', () => {
207
+ const schema = lazy(() => object({ n: number() }))
208
+ expect(generateTypes(schema)).toBe('{\n n: number;\n}')
209
+ })
210
+
211
+ it('returns any when max depth is exhausted', () => {
212
+ expect(generateTypes(number(), { maxDepth: 0 })).toBe('any')
213
+ })
214
+ })
package/src/typegen.ts ADDED
@@ -0,0 +1,268 @@
1
+ import type { Schema } from './schema'
2
+
3
+ const DEFAULT_MAX_DEPTH = 10
4
+
5
+ export type GenerateTypesOptions = {
6
+ maxDepth?: number
7
+ /**
8
+ * ISO 8601 timestamp printed in the autogenerated file banner.
9
+ * Defaults to the time of the `generateTypes` call when the banner is emitted.
10
+ */
11
+ generatedAt?: string
12
+ /**
13
+ * When set to a valid TypeScript identifier, wraps all emitted `export type` declarations (and any
14
+ * trailing root type) in `export namespace Name { ... }` so consumers reference `Name.SomeType`.
15
+ */
16
+ namespace?: string
17
+ }
18
+
19
+ type NamedDeclaration = {
20
+ name: string
21
+ body: string
22
+ comment?: string
23
+ }
24
+
25
+ type TypeGenContext = {
26
+ definitions: Map<string, string>
27
+ declarations: NamedDeclaration[]
28
+ inProgress: Set<string>
29
+ }
30
+
31
+ /**
32
+ * Returns TypeScript for the schema: named `typeName` nodes become `export type` aliases (once each),
33
+ * referenced by name elsewhere. With no named nodes, returns a single inline type expression (same as before).
34
+ *
35
+ * When at least one named type is emitted, the result is prefixed with a banner stating the output is
36
+ * autogenerated and must not be edited manually, plus a generation timestamp.
37
+ *
38
+ * Pass `namespace` to wrap declarations in `export namespace … { … }`.
39
+ */
40
+ export const generateTypes = (schema: Schema, options?: GenerateTypesOptions): string => {
41
+ const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH
42
+ const ctx: TypeGenContext = {
43
+ definitions: new Map(),
44
+ declarations: [],
45
+ inProgress: new Set(),
46
+ }
47
+ const root = emitSchema(schema, maxDepth, ctx, '')
48
+
49
+ if (ctx.declarations.length === 0) {
50
+ return root
51
+ }
52
+
53
+ const declStrings = ctx.declarations.map(formatNamedDeclaration)
54
+ const body = declStrings.join('\n\n')
55
+ const lastDeclared = ctx.declarations.at(-1)?.name
56
+ let content = lastDeclared === root ? body : `${body}\n\n${root}`
57
+ const ns = options?.namespace
58
+ if (ns && isValidTypeScriptIdentifier(ns)) {
59
+ content = wrapDeclarationsInNamespace(ns, content)
60
+ }
61
+ const generatedAt = options?.generatedAt ?? new Date().toISOString()
62
+ return `${formatAutogeneratedFileBanner(generatedAt)}${content}`
63
+ }
64
+
65
+ const formatAutogeneratedFileBanner = (generatedAt: string): string =>
66
+ `/**\n * This file is autogenerated. Do not edit it.\n *\n * Generated at: ${generatedAt}\n */\n\n`
67
+
68
+ const wrapDeclarationsInNamespace = (namespace: string, content: string): string => {
69
+ const body = content
70
+ .split('\n')
71
+ .map((line) => (line === '' ? '' : ` ${line}`))
72
+ .join('\n')
73
+ return `export namespace ${namespace} {\n${body}\n}\n`
74
+ }
75
+
76
+ const formatNamedDeclaration = (d: NamedDeclaration): string => {
77
+ const commentBlock = d.comment ? formatTypeCommentAsJsDoc(d.comment) : ''
78
+ const comment = commentBlock ? `${commentBlock}\n` : ''
79
+ return `${comment}export type ${d.name} = ${d.body}`
80
+ }
81
+
82
+ /** Inline JSDoc for one line; multi-line comments use a newline after the opening delimiter and ` * ` on each line. */
83
+ const formatTypeCommentAsJsDoc = (comment: string): string => {
84
+ const lines = comment.split(/\r?\n/)
85
+ const trimmed = lines.map((line) => line.trim())
86
+ while (trimmed.length > 0 && trimmed[0] === '') {
87
+ trimmed.shift()
88
+ }
89
+ while (trimmed.length > 0 && trimmed.at(-1) === '') {
90
+ trimmed.pop()
91
+ }
92
+ if (trimmed.length === 0) {
93
+ return ''
94
+ }
95
+ if (trimmed.length === 1) {
96
+ return `/** ${trimmed[0]} */`
97
+ }
98
+ const body = trimmed.map((line) => ` * ${line}`).join('\n')
99
+ return `/** \n${body}\n */`
100
+ }
101
+
102
+ /** Prefixes each line of a JSDoc block with `indent` for object property comments. */
103
+ const formatTypeCommentAsIndentedJsDoc = (indent: string, comment: string): string => {
104
+ const doc = formatTypeCommentAsJsDoc(comment)
105
+ if (!doc) {
106
+ return ''
107
+ }
108
+ return doc
109
+ .split('\n')
110
+ .map((line) => `${indent}${line}`)
111
+ .join('\n')
112
+ }
113
+
114
+ const getTypeName = (schema: Schema): string | undefined => {
115
+ if (schema.type === 'lazy' || schema.type === 'evaluate') {
116
+ return undefined
117
+ }
118
+ const name = schema.typeName
119
+ if (!name || !isValidTypeScriptIdentifier(name)) {
120
+ return undefined
121
+ }
122
+ return name
123
+ }
124
+
125
+ const getTypeComment = (schema: Schema): string | undefined => {
126
+ if (schema.type === 'lazy' || schema.type === 'evaluate') {
127
+ return undefined
128
+ }
129
+ if (schema.type === 'optional') {
130
+ return schema.typeComment ?? getTypeComment(schema.schema)
131
+ }
132
+ return schema.typeComment
133
+ }
134
+
135
+ const isValidTypeScriptIdentifier = (name: string): boolean => /^[$_A-Za-z][$_\w]*$/.test(name)
136
+
137
+ const emitSchema = (schema: Schema, depth: number, ctx: TypeGenContext, braceIndent: string): string => {
138
+ const name = getTypeName(schema)
139
+ if (name) {
140
+ if (ctx.definitions.has(name)) {
141
+ return name
142
+ }
143
+ if (ctx.inProgress.has(name)) {
144
+ return name
145
+ }
146
+ ctx.inProgress.add(name)
147
+ const body = structuralEmit(schema, depth, ctx, '')
148
+ ctx.inProgress.delete(name)
149
+ if (!ctx.definitions.has(name)) {
150
+ ctx.definitions.set(name, body)
151
+ ctx.declarations.push({
152
+ name,
153
+ body,
154
+ comment: getTypeComment(schema),
155
+ })
156
+ }
157
+ return name
158
+ }
159
+ return structuralEmit(schema, depth, ctx, braceIndent)
160
+ }
161
+
162
+ const structuralEmit = (schema: Schema, depth: number, ctx: TypeGenContext, braceIndent: string): string => {
163
+ if (depth <= 0) {
164
+ return 'any'
165
+ }
166
+ const next = depth - 1
167
+
168
+ switch (schema.type) {
169
+ case 'number':
170
+ return 'number'
171
+ case 'string':
172
+ return 'string'
173
+ case 'boolean':
174
+ return 'boolean'
175
+ case 'nullable':
176
+ return 'null'
177
+ case 'notDefined':
178
+ return 'undefined'
179
+ case 'any':
180
+ return 'any'
181
+ case 'array': {
182
+ const item = emitSchema(schema.items, next, ctx, braceIndent)
183
+ return needsArrayItemParen(item) ? `(${item})[]` : `${item}[]`
184
+ }
185
+ case 'record': {
186
+ const key = emitSchema(schema.key, next, ctx, braceIndent)
187
+ const value = emitSchema(schema.value, next, ctx, braceIndent)
188
+ return `Record<${key}, ${value}>`
189
+ }
190
+ case 'object': {
191
+ const entries = Object.entries(schema.properties)
192
+ if (entries.length === 0) {
193
+ return '{}'
194
+ }
195
+ const keyIndent = `${braceIndent} `
196
+ const props = entries.map(([key, child]) => {
197
+ const tsKey = /^[$_a-zA-Z][$_\w]*$/.test(key) ? key : JSON.stringify(key)
198
+ const optionalProp = child.type === 'optional'
199
+ const valueSchema = optionalProp ? child.schema : child
200
+ const value = emitSchema(valueSchema, next, ctx, keyIndent)
201
+ const propComment = getTypeComment(child)
202
+ const docBlock = propComment ? formatTypeCommentAsIndentedJsDoc(keyIndent, propComment) : ''
203
+ const propLine = optionalProp ? `${keyIndent}${tsKey}?: ${value};` : `${keyIndent}${tsKey}: ${value};`
204
+ return docBlock ? `${docBlock}\n${propLine}` : propLine
205
+ })
206
+ return `{\n${props.join('\n')}\n${braceIndent}}`
207
+ }
208
+ case 'optional': {
209
+ const inner = emitSchema(schema.schema, next, ctx, braceIndent)
210
+ return `${wrapUnionMember(inner)} | undefined`
211
+ }
212
+ case 'union':
213
+ if (schema.schemas.length === 0) {
214
+ return 'never'
215
+ }
216
+ return schema.schemas.map((s) => wrapUnionMember(emitSchema(s, next, ctx, braceIndent))).join(' | ')
217
+ case 'intersection':
218
+ if (schema.schemas.length === 0) {
219
+ return 'unknown'
220
+ }
221
+ return schema.schemas.map((s) => wrapIntersectionMember(emitSchema(s, next, ctx, braceIndent))).join(' & ')
222
+ case 'literal':
223
+ return literalToTs(schema.value)
224
+ case 'lazy':
225
+ return emitSchema(schema.schema(), next, ctx, braceIndent)
226
+ case 'evaluate':
227
+ return emitSchema(schema.schema, next, ctx, braceIndent)
228
+ default: {
229
+ const _exhaustive: never = schema
230
+ return _exhaustive
231
+ }
232
+ }
233
+ }
234
+
235
+ const literalToTs = (value: string | number | boolean | bigint): string => {
236
+ if (typeof value === 'bigint') {
237
+ return `${value}n`
238
+ }
239
+ return JSON.stringify(value)
240
+ }
241
+
242
+ const needsArrayItemParen = (t: string): boolean => {
243
+ if (t === 'number' || t === 'string' || t === 'boolean' || t === 'null' || t === 'undefined' || t === 'any') {
244
+ return false
245
+ }
246
+ // Union: `A | B[]` is `A | (B[])`; intersection: `A & B[]` is `A & (B[])`. Wrap the whole item type.
247
+ return t.includes(' | ') || t.includes(' & ')
248
+ }
249
+
250
+ const wrapUnionMember = (t: string): string => {
251
+ if (t === 'number' || t === 'string' || t === 'boolean' || t === 'null' || t === 'undefined' || t === 'any') {
252
+ return t
253
+ }
254
+ if (/^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?|-?\d+n|"(?:[^"\\]|\\.)*"|true|false)$/.test(t)) {
255
+ return t
256
+ }
257
+ if (/^[$_A-Za-z][$_\w]*$/.test(t)) {
258
+ return t
259
+ }
260
+ return `(${t})`
261
+ }
262
+
263
+ const wrapIntersectionMember = (t: string): string => {
264
+ if (t.includes(' | ') || t.includes(' & ')) {
265
+ return `(${t})`
266
+ }
267
+ return t
268
+ }
package/src/types.ts CHANGED
@@ -3,12 +3,14 @@ import type {
3
3
  ArraySchema,
4
4
  BooleanSchema,
5
5
  EvaluateSchema,
6
+ IntersectionSchema,
6
7
  LazySchema,
7
8
  LiteralSchema,
8
9
  NotDefinedSchema,
9
10
  NullableSchema,
10
11
  NumberSchema,
11
12
  ObjectSchema,
13
+ OptionalSchema,
12
14
  RecordSchema,
13
15
  StringSchema,
14
16
  UnionSchema,
@@ -17,6 +19,34 @@ import type {
17
19
  // Export Static type with depth limit to prevent infinite recursion
18
20
  export type Static<T> = _Static<T, 10>
19
21
 
22
+ /** Folds a tuple of object schemas into an intersection of their static object types. */
23
+ type IntersectObjectStatics<
24
+ Schemas extends readonly ObjectSchema<any>[],
25
+ Depth extends number,
26
+ > = Schemas extends readonly [infer First extends ObjectSchema<any>, ...infer Rest extends readonly ObjectSchema<any>[]]
27
+ ? _Static<First, Depth> & IntersectObjectStatics<Rest, Depth>
28
+ : {}
29
+
30
+ type OptionalPropertyKeys<P> = {
31
+ [K in keyof P]: P[K] extends OptionalSchema<any> ? K : never
32
+ }[keyof P]
33
+
34
+ type RequiredPropertyKeys<P> = {
35
+ [K in keyof P]: P[K] extends OptionalSchema<any> ? never : K
36
+ }[keyof P]
37
+
38
+ type OptionalSchemaInner<S> = S extends OptionalSchema<infer Inner> ? Inner : never
39
+
40
+ type ObjectStatics<Properties, Depth extends number> = [keyof Properties] extends [never]
41
+ ? {}
42
+ : OptionalPropertyKeys<Properties> extends never
43
+ ? { [K in keyof Properties]: _Static<Properties[K], Prev<Depth>> }
44
+ : RequiredPropertyKeys<Properties> extends never
45
+ ? { [K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>> }
46
+ : { [K in RequiredPropertyKeys<Properties>]: _Static<Properties[K], Prev<Depth>> } & {
47
+ [K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>>
48
+ }
49
+
20
50
  // Internal type with depth counter
21
51
  type _Static<T, Depth extends number = 10> = Depth extends 0
22
52
  ? any
@@ -39,14 +69,18 @@ type _Static<T, Depth extends number = 10> = Depth extends 0
39
69
  : T extends RecordSchema<infer Key, infer Value>
40
70
  ? Record<_Static<Key, Prev<Depth>> & PropertyKey, _Static<Value, Prev<Depth>>>
41
71
  : T extends ObjectSchema<infer Properties>
42
- ? { [K in keyof Properties]: _Static<Properties[K], Prev<Depth>> }
43
- : T extends UnionSchema<infer Schemas>
44
- ? _Static<Schemas[number], Prev<Depth>>
45
- : T extends EvaluateSchema<infer S>
46
- ? _Static<S, Prev<Depth>>
47
- : T extends LazySchema<infer S>
48
- ? _Static<ReturnType<S>, Prev<Depth>>
49
- : never
72
+ ? ObjectStatics<Properties, Depth>
73
+ : T extends OptionalSchema<infer S>
74
+ ? _Static<S, Prev<Depth>> | undefined
75
+ : T extends IntersectionSchema<infer Schemas extends readonly ObjectSchema<any>[]>
76
+ ? IntersectObjectStatics<Schemas, Prev<Depth>>
77
+ : T extends UnionSchema<infer Schemas>
78
+ ? _Static<Schemas[number], Prev<Depth>>
79
+ : T extends EvaluateSchema<infer S>
80
+ ? _Static<S, Prev<Depth>>
81
+ : T extends LazySchema<infer S>
82
+ ? _Static<ReturnType<S>, Prev<Depth>>
83
+ : never
50
84
 
51
85
  // Helper type to decrement depth counter
52
86
  type Prev<T extends number> = T extends 10
@@ -5,6 +5,7 @@ import {
5
5
  array,
6
6
  boolean,
7
7
  evaluate,
8
+ intersection,
8
9
  lazy,
9
10
  literal,
10
11
  notDefined,
@@ -652,6 +653,58 @@ describe('union', () => {
652
653
  })
653
654
  })
654
655
 
656
+ describe('intersection', () => {
657
+ it('passes when the value satisfies every member object schema', () => {
658
+ const T = intersection([object({ a: number(), b: number() }), object({ c: string(), d: string() })])
659
+ expect(validate(T, { a: 1, b: 2, c: 'x', d: 'y' })).toBe(true)
660
+ })
661
+
662
+ it('fails when one member object schema fails', () => {
663
+ const T = intersection([object({ a: number(), b: number() }), object({ c: string(), d: string() })])
664
+ expect(validate(T, { a: 1, b: 2, c: 'x', d: 3 })).toBe(false)
665
+ })
666
+
667
+ it('fails when the first member fails even if later members would pass', () => {
668
+ const T = intersection([object({ x: literal(1) }), object({ y: string() })])
669
+ expect(validate(T, { x: 2, y: 'ok' })).toBe(false)
670
+ })
671
+
672
+ it('requires overlapping keys to satisfy every arm that declares them', () => {
673
+ const T = intersection([object({ id: number() }), object({ id: string() })])
674
+ expect(validate(T, { id: 1 })).toBe(false)
675
+ expect(validate(T, { id: '1' })).toBe(false)
676
+ })
677
+
678
+ it('rejects non-plain objects before member checks', () => {
679
+ const T = intersection([object({ x: number() }), object({ y: number() })])
680
+ expect(validate(T, null)).toBe(false)
681
+ expect(validate(T, undefined)).toBe(false)
682
+ expect(validate(T, 0)).toBe(false)
683
+ expect(validate(T, [])).toBe(false)
684
+ expect(validate(T, new Date())).toBe(false)
685
+ })
686
+
687
+ it('treats an empty intersection as vacuously valid', () => {
688
+ const T = intersection([])
689
+ expect(validate(T, null)).toBe(true)
690
+ expect(validate(T, { a: 1 })).toBe(true)
691
+ })
692
+
693
+ it('matches a single member the same as that object schema alone', () => {
694
+ const O = object({ x: number() })
695
+ const T = intersection([O])
696
+ expect(validate(T, { x: 1 })).toBe(true)
697
+ expect(validate(T, {})).toBe(false)
698
+ expect(validate(O, { x: 1 })).toBe(validate(T, { x: 1 }))
699
+ })
700
+
701
+ it('validates members that use lazy schemas', () => {
702
+ const T = intersection([object({ a: number() }), object({ nested: lazy(() => object({ z: string() })) })])
703
+ expect(validate(T, { a: 1, nested: { z: 'ok' } })).toBe(true)
704
+ expect(validate(T, { a: 1, nested: { z: 1 } })).toBe(false)
705
+ })
706
+ })
707
+
655
708
  describe('notDefined', () => {
656
709
  const T = notDefined()
657
710
  it('Should fail string', () => {
package/src/validate.ts CHANGED
@@ -14,10 +14,12 @@ import type { Schema } from './schema'
14
14
  * - 'notDefined': Only `undefined` is valid.
15
15
  * - 'array': Array with all items validated recursively.
16
16
  * - 'record': Object with string/number keys and values, checked recursively.
17
- * - 'object': Object with fixed property keys, each validated recursively.
17
+ * - 'object': Plain object with fixed property keys, each validated recursively.
18
18
  * - 'union': Accepts if value matches any of the listed schemas.
19
+ * - 'optional': Accepts `undefined` or a value matching the inner schema.
20
+ * - 'intersection': Accepts if value matches every member schema (members are object schemas; value must be a plain object).
19
21
  * - 'literal': Exact match with a literal value.
20
- * - 'recursive': Schema referring to itself for nested validation (e.g. trees).
22
+ * - 'lazy': Delegates to the schema returned by the factory.
21
23
  * - 'evaluate': Transforms value then validates against an inner schema.
22
24
  *
23
25
  * @example
@@ -72,9 +74,22 @@ export const validate = (schema: Schema | undefined, value: unknown): boolean =>
72
74
  const schemaKeys = Object.keys(schema.properties)
73
75
  return schemaKeys.every((key) => validate(schema.properties[key], value[key]))
74
76
  }
77
+ if (schema.type === 'optional') {
78
+ return value === undefined || validate(schema.schema, value)
79
+ }
75
80
  if (schema.type === 'union') {
76
81
  return schema.schemas.some((schema) => validate(schema, value))
77
82
  }
83
+ if (schema.type === 'intersection') {
84
+ if (schema.schemas.length === 0) {
85
+ // Vacuous: no constraints (matches `Array.prototype.every` on an empty list).
86
+ return true
87
+ }
88
+ if (!isObject(value)) {
89
+ return false
90
+ }
91
+ return schema.schemas.every((subSchema) => validate(subSchema, value))
92
+ }
78
93
  if (schema.type === 'literal') {
79
94
  return value === schema.value
80
95
  }