@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.
package/dist/types.d.ts CHANGED
@@ -1,8 +1,24 @@
1
- import type { AnySchema, ArraySchema, BooleanSchema, EvaluateSchema, LazySchema, LiteralSchema, NotDefinedSchema, NullableSchema, NumberSchema, ObjectSchema, RecordSchema, StringSchema, UnionSchema } from './schema.js';
1
+ import type { AnySchema, ArraySchema, BooleanSchema, EvaluateSchema, IntersectionSchema, LazySchema, LiteralSchema, NotDefinedSchema, NullableSchema, NumberSchema, ObjectSchema, OptionalSchema, RecordSchema, StringSchema, UnionSchema } from './schema.js';
2
2
  export type Static<T> = _Static<T, 10>;
3
- 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> ? {
3
+ /** Folds a tuple of object schemas into an intersection of their static object types. */
4
+ type IntersectObjectStatics<Schemas extends readonly ObjectSchema<any>[], Depth extends number> = Schemas extends readonly [infer First extends ObjectSchema<any>, ...infer Rest extends readonly ObjectSchema<any>[]] ? _Static<First, Depth> & IntersectObjectStatics<Rest, Depth> : {};
5
+ type OptionalPropertyKeys<P> = {
6
+ [K in keyof P]: P[K] extends OptionalSchema<any> ? K : never;
7
+ }[keyof P];
8
+ type RequiredPropertyKeys<P> = {
9
+ [K in keyof P]: P[K] extends OptionalSchema<any> ? never : K;
10
+ }[keyof P];
11
+ type OptionalSchemaInner<S> = S extends OptionalSchema<infer Inner> ? Inner : never;
12
+ type ObjectStatics<Properties, Depth extends number> = [keyof Properties] extends [never] ? {} : OptionalPropertyKeys<Properties> extends never ? {
4
13
  [K in keyof Properties]: _Static<Properties[K], Prev<Depth>>;
5
- } : 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;
14
+ } : RequiredPropertyKeys<Properties> extends never ? {
15
+ [K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>>;
16
+ } : {
17
+ [K in RequiredPropertyKeys<Properties>]: _Static<Properties[K], Prev<Depth>>;
18
+ } & {
19
+ [K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>>;
20
+ };
21
+ 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 extends readonly ObjectSchema<any>[]> ? 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;
6
22
  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;
7
23
  export {};
8
24
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,SAAS,EACT,WAAW,EACX,aAAa,EACb,cAAc,EACd,UAAU,EACV,aAAa,EACb,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACZ,MAAM,UAAU,CAAA;AAGjB,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAGtC,KAAK,OAAO,CAAC,CAAC,EAAE,KAAK,SAAS,MAAM,GAAG,EAAE,IAAI,KAAK,SAAS,CAAC,GACxD,GAAG,GACH,CAAC,SAAS,aAAa,CAAC,MAAM,KAAK,CAAC,GAClC,KAAK,GACL,CAAC,SAAS,YAAY,GACpB,MAAM,GACN,CAAC,SAAS,YAAY,GACpB,MAAM,GACN,CAAC,SAAS,aAAa,GACrB,OAAO,GACP,CAAC,SAAS,cAAc,GACtB,IAAI,GACJ,CAAC,SAAS,gBAAgB,GACxB,SAAS,GACT,CAAC,SAAS,SAAS,GACjB,GAAG,GACH,CAAC,SAAS,WAAW,CAAC,MAAM,IAAI,CAAC,GAC/B,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GACjC,CAAC,SAAS,YAAY,CAAC,MAAM,GAAG,EAAE,MAAM,KAAK,CAAC,GAC5C,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,WAAW,EAAE,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAC5E,CAAC,SAAS,YAAY,CAAC,MAAM,UAAU,CAAC,GACtC;KAAG,CAAC,IAAI,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;CAAE,GAChE,CAAC,SAAS,WAAW,CAAC,MAAM,OAAO,CAAC,GAClC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GACrC,CAAC,SAAS,cAAc,CAAC,MAAM,CAAC,CAAC,GAC/B,OAAO,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GACvB,CAAC,SAAS,UAAU,CAAC,MAAM,CAAC,CAAC,GAC3B,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GACnC,KAAK,CAAA;AAGnC,KAAK,IAAI,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,SAAS,EAAE,GACtC,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,SAAS,CAAC,GACT,CAAC,GACD,CAAC,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -12,10 +12,12 @@ import type { Schema } from './schema.js';
12
12
  * - 'notDefined': Only `undefined` is valid.
13
13
  * - 'array': Array with all items validated recursively.
14
14
  * - 'record': Object with string/number keys and values, checked recursively.
15
- * - 'object': Object with fixed property keys, each validated recursively.
15
+ * - 'object': Plain object with fixed property keys, each validated recursively.
16
16
  * - 'union': Accepts if value matches any of the listed schemas.
17
+ * - 'optional': Accepts `undefined` or a value matching the inner schema.
18
+ * - 'intersection': Accepts if value matches every member schema (members are object schemas; value must be a plain object).
17
19
  * - 'literal': Exact match with a literal value.
18
- * - 'recursive': Schema referring to itself for nested validation (e.g. trees).
20
+ * - 'lazy': Delegates to the schema returned by the factory.
19
21
  * - 'evaluate': Transforms value then validates against an inner schema.
20
22
  *
21
23
  * @example
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAEtC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,QAAQ,GAAI,QAAQ,MAAM,GAAG,SAAS,EAAE,OAAO,OAAO,KAAG,OAwDrE,CAAA"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAEtC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,QAAQ,GAAI,QAAQ,MAAM,GAAG,SAAS,EAAE,OAAO,OAAO,KAAG,OAqErE,CAAA"}
package/dist/validate.js CHANGED
@@ -12,10 +12,12 @@ import { isObject } from './helpers/is-object.js';
12
12
  * - 'notDefined': Only `undefined` is valid.
13
13
  * - 'array': Array with all items validated recursively.
14
14
  * - 'record': Object with string/number keys and values, checked recursively.
15
- * - 'object': Object with fixed property keys, each validated recursively.
15
+ * - 'object': Plain object with fixed property keys, each validated recursively.
16
16
  * - 'union': Accepts if value matches any of the listed schemas.
17
+ * - 'optional': Accepts `undefined` or a value matching the inner schema.
18
+ * - 'intersection': Accepts if value matches every member schema (members are object schemas; value must be a plain object).
17
19
  * - 'literal': Exact match with a literal value.
18
- * - 'recursive': Schema referring to itself for nested validation (e.g. trees).
20
+ * - 'lazy': Delegates to the schema returned by the factory.
19
21
  * - 'evaluate': Transforms value then validates against an inner schema.
20
22
  *
21
23
  * @example
@@ -69,9 +71,22 @@ export const validate = (schema, value) => {
69
71
  const schemaKeys = Object.keys(schema.properties);
70
72
  return schemaKeys.every((key) => validate(schema.properties[key], value[key]));
71
73
  }
74
+ if (schema.type === 'optional') {
75
+ return value === undefined || validate(schema.schema, value);
76
+ }
72
77
  if (schema.type === 'union') {
73
78
  return schema.schemas.some((schema) => validate(schema, value));
74
79
  }
80
+ if (schema.type === 'intersection') {
81
+ if (schema.schemas.length === 0) {
82
+ // Vacuous: no constraints (matches `Array.prototype.every` on an empty list).
83
+ return true;
84
+ }
85
+ if (!isObject(value)) {
86
+ return false;
87
+ }
88
+ return schema.schemas.every((subSchema) => validate(subSchema, value));
89
+ }
75
90
  if (schema.type === 'literal') {
76
91
  return value === schema.value;
77
92
  }
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "coerce",
16
16
  "scalar"
17
17
  ],
18
- "version": "0.1.0",
18
+ "version": "0.2.0",
19
19
  "engines": {
20
20
  "node": ">=20"
21
21
  },
@@ -6,6 +6,7 @@ import {
6
6
  array,
7
7
  boolean,
8
8
  evaluate,
9
+ intersection,
9
10
  lazy,
10
11
  literal,
11
12
  notDefined,
@@ -417,6 +418,15 @@ describe('object', () => {
417
418
  y: 2,
418
419
  })
419
420
  })
421
+ it('omits optional properties when the value is undefined', () => {
422
+ const T = object({
423
+ id: number(),
424
+ name: optional(string()),
425
+ })
426
+ expect(coerce(T, { id: 1 })).toEqual({ id: 1 })
427
+ expect(coerce(T, { id: 1, name: undefined })).toEqual({ id: 1 })
428
+ expect(coerce(T, { id: 1, name: 'x' })).toEqual({ id: 1, name: 'x' })
429
+ })
420
430
  })
421
431
 
422
432
  describe('record', () => {
@@ -918,6 +928,63 @@ describe('union', () => {
918
928
  $ref: 'https://example.com/schema',
919
929
  })
920
930
  })
931
+
932
+ it('picks the branch whose type discriminator matches a union of literals', () => {
933
+ const T = union([
934
+ object({
935
+ type: literal('a'),
936
+ a: string(),
937
+ }),
938
+ object({
939
+ type: union([literal('b'), literal('c')]),
940
+ b: string(),
941
+ }),
942
+ ])
943
+ expect(coerce(T, { type: 'a' })).toEqual({ type: 'a', a: '' })
944
+ expect(coerce(T, { type: 'b' })).toEqual({ type: 'b', b: '' })
945
+ expect(coerce(T, { type: 'c' })).toEqual({ type: 'c', b: '' })
946
+ })
947
+ })
948
+
949
+ describe('intersection', () => {
950
+ const T = intersection([
951
+ object({
952
+ a: number(),
953
+ b: number(),
954
+ }),
955
+ object({
956
+ c: string(),
957
+ d: string(),
958
+ }),
959
+ ])
960
+ it('merges coerced properties from each object schema', () => {
961
+ const result = coerce(T, { a: 1, b: 2, c: 'x', d: 'y' })
962
+ expect(result).toEqual({ a: 1, b: 2, c: 'x', d: 'y' })
963
+ })
964
+ it('fills missing keys per branch from the same input value', () => {
965
+ const result = coerce(T, { a: 'nope', c: 123 })
966
+ expect(result).toEqual({ a: 0, b: 0, c: '', d: '' })
967
+ })
968
+ it('later branch wins on overlapping keys', () => {
969
+ const overlap = intersection([object({ x: number() }), object({ x: string() })])
970
+ const result = coerce(overlap, { x: 1 })
971
+ expect(result).toEqual({ x: '' })
972
+ })
973
+ it('wins in a union when every member validates and summed score beats narrower members', () => {
974
+ const A = object({ type: literal('A'), onlyA: number() })
975
+ const B = object({ type: literal('B'), onlyB: string() })
976
+ const both = intersection([
977
+ object({ type: literal('A'), shared: number() }),
978
+ object({ shared: number(), extra: string() }),
979
+ ])
980
+ const T = union([A, B, both])
981
+ // Intersection merges only its declared keys; it outscores A here because both sub-objects validate.
982
+ expect(coerce(T, { type: 'A', onlyA: 1, shared: 2, extra: 'ok' })).toEqual({
983
+ type: 'A',
984
+ shared: 2,
985
+ extra: 'ok',
986
+ })
987
+ })
921
988
  })
922
989
 
923
990
  describe('notDefined', () => {
package/src/coerce.ts CHANGED
@@ -3,6 +3,24 @@ import type { Schema } from './schema'
3
3
  import type { Static } from './types'
4
4
  import { validate } from './validate'
5
5
 
6
+ /**
7
+ * True when this property schema is only used to discriminate union branches
8
+ * (single literal, or a union of literals). No presence bonus when the value
9
+ * does not match — avoids ties like `type: literal('a')` vs `type: union([lit('b'), lit('c')])`.
10
+ */
11
+ const isDiscriminatorProperty = (schema: Schema): boolean => {
12
+ if (schema.type === 'optional') {
13
+ return isDiscriminatorProperty(schema.schema)
14
+ }
15
+ if (schema.type === 'literal') {
16
+ return true
17
+ }
18
+ if (schema.type === 'union') {
19
+ return schema.schemas.length > 0 && schema.schemas.every(isDiscriminatorProperty)
20
+ }
21
+ return false
22
+ }
23
+
6
24
  /**
7
25
  * Computes a "score" indicating how well a value matches a schema,
8
26
  * used for picking the best branch in union coercion.
@@ -17,16 +35,23 @@ const scoreUnion = (schema: Schema, value: unknown): number => {
17
35
  return 0
18
36
  }
19
37
 
20
- // For each key in the schema's properties:
21
- // - +10 if the value matches an explicit literal for the key.
22
- // - +1 if the property exists (not literal match).
38
+ // Missing keys contribute 0 (including optional keys — matches prior union heuristics).
39
+ // Discriminator properties (`literal` or `union` of literals): recurse with scoreUnion;
40
+ // matching values get a high weight (×10) so `type: literal('A')` beats unrelated fields
41
+ // on another branch; mismatches score 0 (no "key present" tie-break).
42
+ // Other properties: scoreUnion plus +1 when the value fails validation so `{ a: null }`
43
+ // can still prefer the branch that declares `a`.
23
44
  return Object.keys(schema.properties).reduce<number>((acc, key) => {
24
- const exists = key in value
25
- const isLiteralMatch = schema.properties[key].type === 'literal' && value[key] === schema.properties[key].value
26
- if (isLiteralMatch) {
27
- return acc + 10
45
+ if (!(key in value)) {
46
+ return acc
28
47
  }
29
- return acc + (exists ? 1 : 0)
48
+ const propSchema = schema.properties[key]
49
+ const raw = value[key as keyof typeof value]
50
+ const base = scoreUnion(propSchema, raw)
51
+ if (isDiscriminatorProperty(propSchema)) {
52
+ return acc + (base > 0 ? base * 10 : 0)
53
+ }
54
+ return acc + (base > 0 ? base : 1)
30
55
  }, 0)
31
56
  }
32
57
  if (schema.type === 'array') {
@@ -37,10 +62,19 @@ const scoreUnion = (schema: Schema, value: unknown): number => {
37
62
  // TODO: implement smarter scoring for records (just a placeholder for now)
38
63
  return isObject(value) ? 1 : 0
39
64
  }
65
+ if (schema.type === 'optional') {
66
+ return value === undefined ? 1 : scoreUnion(schema.schema, value)
67
+ }
40
68
  if (schema.type === 'union') {
41
69
  // For a union, use the highest score among all sub-schemas
42
70
  return Math.max(...schema.schemas.map((schema) => scoreUnion(schema, value)))
43
71
  }
72
+ if (schema.type === 'intersection') {
73
+ if (schema.schemas.length === 0) {
74
+ return 1
75
+ }
76
+ return schema.schemas.reduce((acc, sub) => acc + scoreUnion(sub, value), 0)
77
+ }
44
78
 
45
79
  if (schema.type === 'lazy') {
46
80
  // For a lazy schema, evaluate the inner schema and recurse
@@ -122,6 +156,12 @@ export const coerce = <S extends Schema>(
122
156
  if (schema.type === 'notDefined') {
123
157
  return undefined as unknown as Static<S>
124
158
  }
159
+ if (schema.type === 'optional') {
160
+ if (value === undefined) {
161
+ return undefined as unknown as Static<S>
162
+ }
163
+ return coerce(schema.schema, value, cache)
164
+ }
125
165
  if (schema.type === 'array') {
126
166
  if (!Array.isArray(value)) {
127
167
  return [] as unknown as Static<S>
@@ -139,9 +179,16 @@ export const coerce = <S extends Schema>(
139
179
  if (schema.type === 'object') {
140
180
  const keys = Object.keys(schema.properties)
141
181
  const target = isObject(value) ? value : null
142
- return Object.fromEntries(
143
- keys.map((key) => [key, coerce(schema.properties[key], target?.[key], cache)]),
144
- ) as unknown as Static<S>
182
+ const entries: [string, unknown][] = []
183
+ for (const key of keys) {
184
+ const propSchema = schema.properties[key]
185
+ const raw = target?.[key as keyof typeof target]
186
+ if (propSchema.type === 'optional' && raw === undefined) {
187
+ continue
188
+ }
189
+ entries.push([key, coerce(propSchema, raw, cache)])
190
+ }
191
+ return Object.fromEntries(entries) as unknown as Static<S>
145
192
  }
146
193
  if (schema.type === 'union') {
147
194
  const branch = schema.schemas.reduce(
@@ -154,6 +201,12 @@ export const coerce = <S extends Schema>(
154
201
  // We need some way to pick one of the union values
155
202
  return coerce(branch.schema, value, cache)
156
203
  }
204
+ if (schema.type === 'intersection') {
205
+ return schema.schemas.reduce<Record<string, unknown>>(
206
+ (acc, subSchema) => Object.assign(acc, coerce(subSchema, value, cache) as Record<string, unknown>),
207
+ {},
208
+ ) as unknown as Static<S>
209
+ }
157
210
  if (schema.type === 'literal') {
158
211
  return schema.value
159
212
  }
package/src/index.ts CHANGED
@@ -1,10 +1,26 @@
1
1
  export { coerce } from './coerce'
2
2
  export {
3
+ type AnySchema,
4
+ type ArraySchema,
5
+ type BooleanSchema,
6
+ type EvaluateSchema,
7
+ type IntersectionSchema,
8
+ type LazySchema,
9
+ type LiteralSchema,
10
+ type NotDefinedSchema,
11
+ type NullableSchema,
12
+ type NumberSchema,
13
+ type ObjectSchema,
14
+ type OptionalSchema,
15
+ type RecordSchema,
3
16
  type Schema,
17
+ type StringSchema,
18
+ type UnionSchema,
4
19
  any,
5
20
  array,
6
21
  boolean,
7
22
  evaluate,
23
+ intersection,
8
24
  lazy,
9
25
  literal,
10
26
  notDefined,
@@ -16,5 +32,6 @@ export {
16
32
  string,
17
33
  union,
18
34
  } from './schema'
35
+ export { type GenerateTypesOptions, generateTypes } from './typegen'
19
36
  export type { Static } from './types'
20
37
  export { validate } from './validate'
package/src/schema.ts CHANGED
@@ -1,63 +1,89 @@
1
+ /**
2
+ * Optional metadata for type generation and documentation.
3
+ * - typeName: Used as the exported TypeScript type name if valid.
4
+ * - typeComment: Adds a JSDoc comment to the generated type declaration.
5
+ */
6
+ type Documentation = Partial<{
7
+ /** Adds a JSDoc comment to the generated type declaration. */
8
+ typeComment: string
9
+ /** Used as the exported TypeScript type name if valid. */
10
+ typeName: string
11
+ }>
12
+
1
13
  /** Schema for finite numeric values. {@link Static} resolves to `number`. */
2
14
  export type NumberSchema = {
3
15
  type: 'number'
4
- }
16
+ } & Documentation
5
17
 
6
18
  /** Schema for string values. {@link Static} resolves to `string`. */
7
19
  export type StringSchema = {
8
20
  type: 'string'
9
- }
21
+ } & Documentation
10
22
 
11
23
  /** Schema for boolean values. {@link Static} resolves to `boolean`. */
12
24
  export type BooleanSchema = {
13
25
  type: 'boolean'
14
- }
26
+ } & Documentation
15
27
 
16
28
  /** Schema for `null`. {@link Static} resolves to `null`. */
17
29
  export type NullableSchema = {
18
30
  type: 'nullable'
19
- }
31
+ } & Documentation
20
32
 
21
33
  /** Schema for a missing or omitted value. {@link Static} resolves to `undefined`. */
22
34
  export type NotDefinedSchema = {
23
35
  type: 'notDefined'
24
- }
36
+ } & Documentation
25
37
 
26
38
  /** Schema that accepts any value without narrowing. {@link Static} resolves to `any`. */
27
39
  export type AnySchema = {
28
40
  type: 'any'
29
- }
41
+ } & Documentation
30
42
 
31
43
  /** Schema for homogeneous lists. {@link Static} resolves to an array of the item static type. */
32
44
  export type ArraySchema<Item extends Schema> = {
33
45
  type: 'array'
34
46
  items: Item
35
- }
47
+ } & Documentation
36
48
 
37
49
  /** Schema for key-value maps with uniform value shape. Keys are constrained to string or number schemas. */
38
50
  export type RecordSchema<Key extends StringSchema | NumberSchema | AnySchema, Value extends Schema> = {
39
51
  type: 'record'
40
52
  key: Key
41
53
  value: Value
42
- }
54
+ } & Documentation
43
55
 
44
56
  /** Schema for objects with a fixed set of named properties, each with its own schema. */
45
57
  export type ObjectSchema<Properties extends Record<string, Schema>> = {
46
58
  type: 'object'
47
59
  properties: Properties
48
- }
60
+ } & Documentation
49
61
 
50
62
  /** Schema that matches if any member schema matches (discriminated union when literals or object tags differ). */
51
63
  export type UnionSchema<Schemas extends Schema[]> = {
52
64
  type: 'union'
53
65
  schemas: Schemas
54
- }
66
+ } & Documentation
67
+
68
+ /**
69
+ * Schema that accepts `undefined` or a value matching the inner schema.
70
+ * In {@link Static} and type generation, object properties use `key?:` instead of `T | undefined`.
71
+ */
72
+ export type OptionalSchema<S extends Schema> = {
73
+ type: 'optional'
74
+ schema: S
75
+ } & Documentation
76
+
77
+ export type IntersectionSchema<Schemas extends readonly ObjectSchema<any>[]> = {
78
+ type: 'intersection'
79
+ schemas: Schemas
80
+ } & Documentation
55
81
 
56
82
  /** Schema for a single exact constant (string, number, boolean, or bigint). {@link Static} is that literal type. */
57
83
  export type LiteralSchema<T extends string | number | boolean | bigint> = {
58
84
  type: 'literal'
59
85
  value: T
60
- }
86
+ } & Documentation
61
87
 
62
88
  /**
63
89
  * Schema for self-referential or recursive types (such as trees or linked lists).
@@ -90,59 +116,100 @@ export type Schema =
90
116
  | RecordSchema<any, any>
91
117
  | ObjectSchema<Record<string, any>>
92
118
  | UnionSchema<any[]>
119
+ | OptionalSchema<any>
120
+ | IntersectionSchema<readonly ObjectSchema<any>[]>
93
121
  | LiteralSchema<any>
94
122
  | LazySchema<any>
95
123
  | EvaluateSchema<any>
96
124
 
97
- const number = (): NumberSchema => ({
125
+ const number = (options?: Documentation): NumberSchema => ({
98
126
  type: 'number',
127
+ typeName: options?.typeName,
128
+ typeComment: options?.typeComment,
99
129
  })
100
130
 
101
- const string = (): StringSchema => ({
131
+ const string = (options?: Documentation): StringSchema => ({
102
132
  type: 'string',
133
+ typeName: options?.typeName,
134
+ typeComment: options?.typeComment,
103
135
  })
104
136
 
105
- const boolean = (): BooleanSchema => ({
137
+ const boolean = (options?: Documentation): BooleanSchema => ({
106
138
  type: 'boolean',
139
+ typeName: options?.typeName,
140
+ typeComment: options?.typeComment,
107
141
  })
108
142
 
109
- const nullable = (): NullableSchema => ({
143
+ const nullable = (options?: Documentation): NullableSchema => ({
110
144
  type: 'nullable',
145
+ typeName: options?.typeName,
146
+ typeComment: options?.typeComment,
111
147
  })
112
148
 
113
- const notDefined = (): NotDefinedSchema => ({
149
+ const notDefined = (options?: Documentation): NotDefinedSchema => ({
114
150
  type: 'notDefined',
151
+ typeName: options?.typeName,
152
+ typeComment: options?.typeComment,
115
153
  })
116
154
 
117
- const any = (): AnySchema => ({
155
+ const any = (options?: Documentation): AnySchema => ({
118
156
  type: 'any',
157
+ typeName: options?.typeName,
158
+ typeComment: options?.typeComment,
119
159
  })
120
160
 
121
- const array = <Item extends Schema>(items: Item): ArraySchema<Item> => ({
161
+ const array = <Item extends Schema>(items: Item, options?: Documentation): ArraySchema<Item> => ({
122
162
  type: 'array',
123
163
  items,
164
+ typeName: options?.typeName,
165
+ typeComment: options?.typeComment,
124
166
  })
125
167
 
126
168
  const record = <Key extends StringSchema | AnySchema, Value extends Schema>(
127
169
  key: Key,
128
170
  value: Value,
171
+ options?: Documentation,
129
172
  ): RecordSchema<Key, Value> => ({
130
173
  type: 'record',
131
174
  key,
132
175
  value,
176
+ typeName: options?.typeName,
177
+ typeComment: options?.typeComment,
133
178
  })
134
179
 
135
- const object = <Properties extends Record<string, Schema>>(properties: Properties): ObjectSchema<Properties> => ({
180
+ const object = <Properties extends Record<string, Schema>>(
181
+ properties: Properties,
182
+ options?: Documentation,
183
+ ): ObjectSchema<Properties> => ({
136
184
  type: 'object',
137
185
  properties,
186
+ typeName: options?.typeName,
187
+ typeComment: options?.typeComment,
138
188
  })
139
189
 
140
- const union = <Schemas extends Schema[]>(schemas: Schemas): UnionSchema<Schemas> => ({
190
+ const union = <Schemas extends Schema[]>(schemas: Schemas, options?: Documentation): UnionSchema<Schemas> => ({
141
191
  type: 'union',
142
192
  schemas,
193
+ typeName: options?.typeName,
194
+ typeComment: options?.typeComment,
195
+ })
196
+
197
+ const intersection = <Schemas extends readonly ObjectSchema<any>[]>(
198
+ schemas: Schemas,
199
+ options?: Documentation,
200
+ ): IntersectionSchema<Schemas> => ({
201
+ type: 'intersection',
202
+ schemas,
203
+ typeName: options?.typeName,
204
+ typeComment: options?.typeComment,
143
205
  })
144
206
 
145
- const optional = <S extends Schema>(schema: S) => union([schema, notDefined()])
207
+ const optional = <S extends Schema>(schema: S, options?: Documentation): OptionalSchema<S> => ({
208
+ type: 'optional',
209
+ schema,
210
+ typeName: options?.typeName,
211
+ typeComment: options?.typeComment,
212
+ })
146
213
 
147
214
  const literal = <Value extends string | number | boolean | bigint>(value: Value): LiteralSchema<Value> => ({
148
215
  type: 'literal',
@@ -171,6 +238,7 @@ export {
171
238
  record,
172
239
  object,
173
240
  union,
241
+ intersection,
174
242
  optional,
175
243
  literal,
176
244
  lazy,