@scalar/validation 0.1.0 → 0.3.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +60 -9
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/schema.d.ts +52 -24
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +43 -12
- package/dist/typegen.d.ts +25 -0
- package/dist/typegen.d.ts.map +1 -0
- package/dist/typegen.js +221 -0
- package/dist/types.d.ts +23 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/validate.d.ts +4 -2
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +17 -2
- package/package.json +1 -1
- package/src/coerce.test.ts +87 -0
- package/src/coerce.ts +64 -11
- package/src/index.ts +17 -0
- package/src/schema.ts +93 -21
- package/src/typegen.test.ts +214 -0
- package/src/typegen.ts +268 -0
- package/src/types.ts +46 -8
- package/src/validate.test.ts +53 -0
- package/src/validate.ts +17 -2
package/dist/typegen.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const DEFAULT_MAX_DEPTH = 10;
|
|
2
|
+
/**
|
|
3
|
+
* Returns TypeScript for the schema: named `typeName` nodes become `export type` aliases (once each),
|
|
4
|
+
* referenced by name elsewhere. With no named nodes, returns a single inline type expression (same as before).
|
|
5
|
+
*
|
|
6
|
+
* When at least one named type is emitted, the result is prefixed with a banner stating the output is
|
|
7
|
+
* autogenerated and must not be edited manually, plus a generation timestamp.
|
|
8
|
+
*
|
|
9
|
+
* Pass `namespace` to wrap declarations in `export namespace … { … }`.
|
|
10
|
+
*/
|
|
11
|
+
export const generateTypes = (schema, options) => {
|
|
12
|
+
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
13
|
+
const ctx = {
|
|
14
|
+
definitions: new Map(),
|
|
15
|
+
declarations: [],
|
|
16
|
+
inProgress: new Set(),
|
|
17
|
+
};
|
|
18
|
+
const root = emitSchema(schema, maxDepth, ctx, '');
|
|
19
|
+
if (ctx.declarations.length === 0) {
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
const declStrings = ctx.declarations.map(formatNamedDeclaration);
|
|
23
|
+
const body = declStrings.join('\n\n');
|
|
24
|
+
const lastDeclared = ctx.declarations.at(-1)?.name;
|
|
25
|
+
let content = lastDeclared === root ? body : `${body}\n\n${root}`;
|
|
26
|
+
const ns = options?.namespace;
|
|
27
|
+
if (ns && isValidTypeScriptIdentifier(ns)) {
|
|
28
|
+
content = wrapDeclarationsInNamespace(ns, content);
|
|
29
|
+
}
|
|
30
|
+
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
|
31
|
+
return `${formatAutogeneratedFileBanner(generatedAt)}${content}`;
|
|
32
|
+
};
|
|
33
|
+
const formatAutogeneratedFileBanner = (generatedAt) => `/**\n * This file is autogenerated. Do not edit it.\n *\n * Generated at: ${generatedAt}\n */\n\n`;
|
|
34
|
+
const wrapDeclarationsInNamespace = (namespace, content) => {
|
|
35
|
+
const body = content
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line) => (line === '' ? '' : ` ${line}`))
|
|
38
|
+
.join('\n');
|
|
39
|
+
return `export namespace ${namespace} {\n${body}\n}\n`;
|
|
40
|
+
};
|
|
41
|
+
const formatNamedDeclaration = (d) => {
|
|
42
|
+
const commentBlock = d.comment ? formatTypeCommentAsJsDoc(d.comment) : '';
|
|
43
|
+
const comment = commentBlock ? `${commentBlock}\n` : '';
|
|
44
|
+
return `${comment}export type ${d.name} = ${d.body}`;
|
|
45
|
+
};
|
|
46
|
+
/** Inline JSDoc for one line; multi-line comments use a newline after the opening delimiter and ` * ` on each line. */
|
|
47
|
+
const formatTypeCommentAsJsDoc = (comment) => {
|
|
48
|
+
const lines = comment.split(/\r?\n/);
|
|
49
|
+
const trimmed = lines.map((line) => line.trim());
|
|
50
|
+
while (trimmed.length > 0 && trimmed[0] === '') {
|
|
51
|
+
trimmed.shift();
|
|
52
|
+
}
|
|
53
|
+
while (trimmed.length > 0 && trimmed.at(-1) === '') {
|
|
54
|
+
trimmed.pop();
|
|
55
|
+
}
|
|
56
|
+
if (trimmed.length === 0) {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
if (trimmed.length === 1) {
|
|
60
|
+
return `/** ${trimmed[0]} */`;
|
|
61
|
+
}
|
|
62
|
+
const body = trimmed.map((line) => ` * ${line}`).join('\n');
|
|
63
|
+
return `/** \n${body}\n */`;
|
|
64
|
+
};
|
|
65
|
+
/** Prefixes each line of a JSDoc block with `indent` for object property comments. */
|
|
66
|
+
const formatTypeCommentAsIndentedJsDoc = (indent, comment) => {
|
|
67
|
+
const doc = formatTypeCommentAsJsDoc(comment);
|
|
68
|
+
if (!doc) {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
return doc
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((line) => `${indent}${line}`)
|
|
74
|
+
.join('\n');
|
|
75
|
+
};
|
|
76
|
+
const getTypeName = (schema) => {
|
|
77
|
+
if (schema.type === 'lazy' || schema.type === 'evaluate') {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const name = schema.typeName;
|
|
81
|
+
if (!name || !isValidTypeScriptIdentifier(name)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return name;
|
|
85
|
+
};
|
|
86
|
+
const getTypeComment = (schema) => {
|
|
87
|
+
if (schema.type === 'lazy' || schema.type === 'evaluate') {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (schema.type === 'optional') {
|
|
91
|
+
return schema.typeComment ?? getTypeComment(schema.schema);
|
|
92
|
+
}
|
|
93
|
+
return schema.typeComment;
|
|
94
|
+
};
|
|
95
|
+
const isValidTypeScriptIdentifier = (name) => /^[$_A-Za-z][$_\w]*$/.test(name);
|
|
96
|
+
const emitSchema = (schema, depth, ctx, braceIndent) => {
|
|
97
|
+
const name = getTypeName(schema);
|
|
98
|
+
if (name) {
|
|
99
|
+
if (ctx.definitions.has(name)) {
|
|
100
|
+
return name;
|
|
101
|
+
}
|
|
102
|
+
if (ctx.inProgress.has(name)) {
|
|
103
|
+
return name;
|
|
104
|
+
}
|
|
105
|
+
ctx.inProgress.add(name);
|
|
106
|
+
const body = structuralEmit(schema, depth, ctx, '');
|
|
107
|
+
ctx.inProgress.delete(name);
|
|
108
|
+
if (!ctx.definitions.has(name)) {
|
|
109
|
+
ctx.definitions.set(name, body);
|
|
110
|
+
ctx.declarations.push({
|
|
111
|
+
name,
|
|
112
|
+
body,
|
|
113
|
+
comment: getTypeComment(schema),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return name;
|
|
117
|
+
}
|
|
118
|
+
return structuralEmit(schema, depth, ctx, braceIndent);
|
|
119
|
+
};
|
|
120
|
+
const structuralEmit = (schema, depth, ctx, braceIndent) => {
|
|
121
|
+
if (depth <= 0) {
|
|
122
|
+
return 'any';
|
|
123
|
+
}
|
|
124
|
+
const next = depth - 1;
|
|
125
|
+
switch (schema.type) {
|
|
126
|
+
case 'number':
|
|
127
|
+
return 'number';
|
|
128
|
+
case 'string':
|
|
129
|
+
return 'string';
|
|
130
|
+
case 'boolean':
|
|
131
|
+
return 'boolean';
|
|
132
|
+
case 'nullable':
|
|
133
|
+
return 'null';
|
|
134
|
+
case 'notDefined':
|
|
135
|
+
return 'undefined';
|
|
136
|
+
case 'any':
|
|
137
|
+
return 'any';
|
|
138
|
+
case 'array': {
|
|
139
|
+
const item = emitSchema(schema.items, next, ctx, braceIndent);
|
|
140
|
+
return needsArrayItemParen(item) ? `(${item})[]` : `${item}[]`;
|
|
141
|
+
}
|
|
142
|
+
case 'record': {
|
|
143
|
+
const key = emitSchema(schema.key, next, ctx, braceIndent);
|
|
144
|
+
const value = emitSchema(schema.value, next, ctx, braceIndent);
|
|
145
|
+
return `Record<${key}, ${value}>`;
|
|
146
|
+
}
|
|
147
|
+
case 'object': {
|
|
148
|
+
const entries = Object.entries(schema.properties);
|
|
149
|
+
if (entries.length === 0) {
|
|
150
|
+
return '{}';
|
|
151
|
+
}
|
|
152
|
+
const keyIndent = `${braceIndent} `;
|
|
153
|
+
const props = entries.map(([key, child]) => {
|
|
154
|
+
const tsKey = /^[$_a-zA-Z][$_\w]*$/.test(key) ? key : JSON.stringify(key);
|
|
155
|
+
const optionalProp = child.type === 'optional';
|
|
156
|
+
const valueSchema = optionalProp ? child.schema : child;
|
|
157
|
+
const value = emitSchema(valueSchema, next, ctx, keyIndent);
|
|
158
|
+
const propComment = getTypeComment(child);
|
|
159
|
+
const docBlock = propComment ? formatTypeCommentAsIndentedJsDoc(keyIndent, propComment) : '';
|
|
160
|
+
const propLine = optionalProp ? `${keyIndent}${tsKey}?: ${value};` : `${keyIndent}${tsKey}: ${value};`;
|
|
161
|
+
return docBlock ? `${docBlock}\n${propLine}` : propLine;
|
|
162
|
+
});
|
|
163
|
+
return `{\n${props.join('\n')}\n${braceIndent}}`;
|
|
164
|
+
}
|
|
165
|
+
case 'optional': {
|
|
166
|
+
const inner = emitSchema(schema.schema, next, ctx, braceIndent);
|
|
167
|
+
return `${wrapUnionMember(inner)} | undefined`;
|
|
168
|
+
}
|
|
169
|
+
case 'union':
|
|
170
|
+
if (schema.schemas.length === 0) {
|
|
171
|
+
return 'never';
|
|
172
|
+
}
|
|
173
|
+
return schema.schemas.map((s) => wrapUnionMember(emitSchema(s, next, ctx, braceIndent))).join(' | ');
|
|
174
|
+
case 'intersection':
|
|
175
|
+
if (schema.schemas.length === 0) {
|
|
176
|
+
return 'unknown';
|
|
177
|
+
}
|
|
178
|
+
return schema.schemas.map((s) => wrapIntersectionMember(emitSchema(s, next, ctx, braceIndent))).join(' & ');
|
|
179
|
+
case 'literal':
|
|
180
|
+
return literalToTs(schema.value);
|
|
181
|
+
case 'lazy':
|
|
182
|
+
return emitSchema(schema.schema(), next, ctx, braceIndent);
|
|
183
|
+
case 'evaluate':
|
|
184
|
+
return emitSchema(schema.schema, next, ctx, braceIndent);
|
|
185
|
+
default: {
|
|
186
|
+
const _exhaustive = schema;
|
|
187
|
+
return _exhaustive;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const literalToTs = (value) => {
|
|
192
|
+
if (typeof value === 'bigint') {
|
|
193
|
+
return `${value}n`;
|
|
194
|
+
}
|
|
195
|
+
return JSON.stringify(value);
|
|
196
|
+
};
|
|
197
|
+
const needsArrayItemParen = (t) => {
|
|
198
|
+
if (t === 'number' || t === 'string' || t === 'boolean' || t === 'null' || t === 'undefined' || t === 'any') {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
// Union: `A | B[]` is `A | (B[])`; intersection: `A & B[]` is `A & (B[])`. Wrap the whole item type.
|
|
202
|
+
return t.includes(' | ') || t.includes(' & ');
|
|
203
|
+
};
|
|
204
|
+
const wrapUnionMember = (t) => {
|
|
205
|
+
if (t === 'number' || t === 'string' || t === 'boolean' || t === 'null' || t === 'undefined' || t === 'any') {
|
|
206
|
+
return t;
|
|
207
|
+
}
|
|
208
|
+
if (/^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?|-?\d+n|"(?:[^"\\]|\\.)*"|true|false)$/.test(t)) {
|
|
209
|
+
return t;
|
|
210
|
+
}
|
|
211
|
+
if (/^[$_A-Za-z][$_\w]*$/.test(t)) {
|
|
212
|
+
return t;
|
|
213
|
+
}
|
|
214
|
+
return `(${t})`;
|
|
215
|
+
};
|
|
216
|
+
const wrapIntersectionMember = (t) => {
|
|
217
|
+
if (t.includes(' | ') || t.includes(' & ')) {
|
|
218
|
+
return `(${t})`;
|
|
219
|
+
}
|
|
220
|
+
return t;
|
|
221
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
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, Schema, StringSchema, UnionSchema } from './schema.js';
|
|
2
2
|
export type Static<T> = _Static<T, 10>;
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Folds intersection member schemas into an intersection of their static types.
|
|
5
|
+
* Uses `Schema` for tuple positions (not a narrower alias) so `infer First extends …` does not
|
|
6
|
+
* reject valid tuple elements and collapse to `{}`.
|
|
7
|
+
*/
|
|
8
|
+
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> : {};
|
|
9
|
+
type OptionalPropertyKeys<P> = {
|
|
10
|
+
[K in keyof P]: P[K] extends OptionalSchema<any> ? K : never;
|
|
11
|
+
}[keyof P];
|
|
12
|
+
type RequiredPropertyKeys<P> = {
|
|
13
|
+
[K in keyof P]: P[K] extends OptionalSchema<any> ? never : K;
|
|
14
|
+
}[keyof P];
|
|
15
|
+
type OptionalSchemaInner<S> = S extends OptionalSchema<infer Inner> ? Inner : never;
|
|
16
|
+
type ObjectStatics<Properties, Depth extends number> = [keyof Properties] extends [never] ? {} : OptionalPropertyKeys<Properties> extends never ? {
|
|
4
17
|
[K in keyof Properties]: _Static<Properties[K], Prev<Depth>>;
|
|
5
|
-
} :
|
|
18
|
+
} : RequiredPropertyKeys<Properties> extends never ? {
|
|
19
|
+
[K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>>;
|
|
20
|
+
} : {
|
|
21
|
+
[K in RequiredPropertyKeys<Properties>]: _Static<Properties[K], Prev<Depth>>;
|
|
22
|
+
} & {
|
|
23
|
+
[K in OptionalPropertyKeys<Properties>]?: _Static<OptionalSchemaInner<Properties[K]>, Prev<Depth>>;
|
|
24
|
+
};
|
|
25
|
+
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;
|
|
6
26
|
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
27
|
export {};
|
|
8
28
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/validate.d.ts
CHANGED
|
@@ -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':
|
|
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
|
-
* - '
|
|
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
|
package/dist/validate.d.ts.map
CHANGED
|
@@ -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
|
|
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':
|
|
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
|
-
* - '
|
|
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
package/src/coerce.test.ts
CHANGED
|
@@ -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,83 @@ 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
|
+
})
|
|
988
|
+
it('correctly picks the branch on an intersection with nested unions #1', () => {
|
|
989
|
+
const T = intersection([
|
|
990
|
+
union([object({ type: literal('a'), a: string() }), object({ type: literal('b'), b: string() })]),
|
|
991
|
+
object({ c: string() }),
|
|
992
|
+
])
|
|
993
|
+
expect(coerce(T, { type: 'a', a: 'x', c: 'y' })).toEqual({ type: 'a', a: 'x', c: 'y' })
|
|
994
|
+
expect(coerce(T, { type: 'b', b: 'x', c: 'y' })).toEqual({ type: 'b', b: 'x', c: 'y' })
|
|
995
|
+
expect(coerce(T, { c: 'y' })).toEqual({ type: 'a', a: '', c: 'y' })
|
|
996
|
+
})
|
|
997
|
+
it('correctly picks the branch on an intersection with nested unions #2', () => {
|
|
998
|
+
const T = intersection([
|
|
999
|
+
union([
|
|
1000
|
+
object({ type: literal('a'), a: optional(string()) }),
|
|
1001
|
+
object({ type: literal('b'), b: optional(string()) }),
|
|
1002
|
+
]),
|
|
1003
|
+
object({ c: optional(string()), d: optional(string()) }),
|
|
1004
|
+
])
|
|
1005
|
+
expect(coerce(T, { a: 'a' })).toEqual({ type: 'a', a: 'a' })
|
|
1006
|
+
expect(coerce(T, { a: 'a', c: 'c' })).toEqual({ type: 'a', a: 'a', c: 'c' })
|
|
1007
|
+
})
|
|
921
1008
|
})
|
|
922
1009
|
|
|
923
1010
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
if (isLiteralMatch) {
|
|
27
|
-
return acc + 10
|
|
45
|
+
if (!(key in value)) {
|
|
46
|
+
return acc
|
|
28
47
|
}
|
|
29
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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'
|