@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/src/schema.ts
CHANGED
|
@@ -1,63 +1,93 @@
|
|
|
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
|
+
/**
|
|
78
|
+
* `UnionSchema<any>` avoids a variance pitfall: `UnionSchema<[A, B]>` is not assignable to
|
|
79
|
+
* `UnionSchema<ObjectSchema<any>[]>`, which breaks `infer` when resolving `Static`.
|
|
80
|
+
*/
|
|
81
|
+
export type IntersectionSchema<Schemas extends readonly (ObjectSchema<any> | UnionSchema<any>)[]> = {
|
|
82
|
+
type: 'intersection'
|
|
83
|
+
schemas: Schemas
|
|
84
|
+
} & Documentation
|
|
55
85
|
|
|
56
86
|
/** Schema for a single exact constant (string, number, boolean, or bigint). {@link Static} is that literal type. */
|
|
57
87
|
export type LiteralSchema<T extends string | number | boolean | bigint> = {
|
|
58
88
|
type: 'literal'
|
|
59
89
|
value: T
|
|
60
|
-
}
|
|
90
|
+
} & Documentation
|
|
61
91
|
|
|
62
92
|
/**
|
|
63
93
|
* Schema for self-referential or recursive types (such as trees or linked lists).
|
|
@@ -90,59 +120,100 @@ export type Schema =
|
|
|
90
120
|
| RecordSchema<any, any>
|
|
91
121
|
| ObjectSchema<Record<string, any>>
|
|
92
122
|
| UnionSchema<any[]>
|
|
123
|
+
| OptionalSchema<any>
|
|
124
|
+
| IntersectionSchema<readonly (ObjectSchema<any> | UnionSchema<ObjectSchema<any>[]>)[]>
|
|
93
125
|
| LiteralSchema<any>
|
|
94
126
|
| LazySchema<any>
|
|
95
127
|
| EvaluateSchema<any>
|
|
96
128
|
|
|
97
|
-
const number = (): NumberSchema => ({
|
|
129
|
+
const number = (options?: Documentation): NumberSchema => ({
|
|
98
130
|
type: 'number',
|
|
131
|
+
typeName: options?.typeName,
|
|
132
|
+
typeComment: options?.typeComment,
|
|
99
133
|
})
|
|
100
134
|
|
|
101
|
-
const string = (): StringSchema => ({
|
|
135
|
+
const string = (options?: Documentation): StringSchema => ({
|
|
102
136
|
type: 'string',
|
|
137
|
+
typeName: options?.typeName,
|
|
138
|
+
typeComment: options?.typeComment,
|
|
103
139
|
})
|
|
104
140
|
|
|
105
|
-
const boolean = (): BooleanSchema => ({
|
|
141
|
+
const boolean = (options?: Documentation): BooleanSchema => ({
|
|
106
142
|
type: 'boolean',
|
|
143
|
+
typeName: options?.typeName,
|
|
144
|
+
typeComment: options?.typeComment,
|
|
107
145
|
})
|
|
108
146
|
|
|
109
|
-
const nullable = (): NullableSchema => ({
|
|
147
|
+
const nullable = (options?: Documentation): NullableSchema => ({
|
|
110
148
|
type: 'nullable',
|
|
149
|
+
typeName: options?.typeName,
|
|
150
|
+
typeComment: options?.typeComment,
|
|
111
151
|
})
|
|
112
152
|
|
|
113
|
-
const notDefined = (): NotDefinedSchema => ({
|
|
153
|
+
const notDefined = (options?: Documentation): NotDefinedSchema => ({
|
|
114
154
|
type: 'notDefined',
|
|
155
|
+
typeName: options?.typeName,
|
|
156
|
+
typeComment: options?.typeComment,
|
|
115
157
|
})
|
|
116
158
|
|
|
117
|
-
const any = (): AnySchema => ({
|
|
159
|
+
const any = (options?: Documentation): AnySchema => ({
|
|
118
160
|
type: 'any',
|
|
161
|
+
typeName: options?.typeName,
|
|
162
|
+
typeComment: options?.typeComment,
|
|
119
163
|
})
|
|
120
164
|
|
|
121
|
-
const array = <Item extends Schema>(items: Item): ArraySchema<Item> => ({
|
|
165
|
+
const array = <Item extends Schema>(items: Item, options?: Documentation): ArraySchema<Item> => ({
|
|
122
166
|
type: 'array',
|
|
123
167
|
items,
|
|
168
|
+
typeName: options?.typeName,
|
|
169
|
+
typeComment: options?.typeComment,
|
|
124
170
|
})
|
|
125
171
|
|
|
126
172
|
const record = <Key extends StringSchema | AnySchema, Value extends Schema>(
|
|
127
173
|
key: Key,
|
|
128
174
|
value: Value,
|
|
175
|
+
options?: Documentation,
|
|
129
176
|
): RecordSchema<Key, Value> => ({
|
|
130
177
|
type: 'record',
|
|
131
178
|
key,
|
|
132
179
|
value,
|
|
180
|
+
typeName: options?.typeName,
|
|
181
|
+
typeComment: options?.typeComment,
|
|
133
182
|
})
|
|
134
183
|
|
|
135
|
-
const object = <Properties extends Record<string, Schema>>(
|
|
184
|
+
const object = <Properties extends Record<string, Schema>>(
|
|
185
|
+
properties: Properties,
|
|
186
|
+
options?: Documentation,
|
|
187
|
+
): ObjectSchema<Properties> => ({
|
|
136
188
|
type: 'object',
|
|
137
189
|
properties,
|
|
190
|
+
typeName: options?.typeName,
|
|
191
|
+
typeComment: options?.typeComment,
|
|
138
192
|
})
|
|
139
193
|
|
|
140
|
-
const union = <Schemas extends Schema[]>(schemas: Schemas): UnionSchema<Schemas> => ({
|
|
194
|
+
const union = <Schemas extends Schema[]>(schemas: Schemas, options?: Documentation): UnionSchema<Schemas> => ({
|
|
141
195
|
type: 'union',
|
|
142
196
|
schemas,
|
|
197
|
+
typeName: options?.typeName,
|
|
198
|
+
typeComment: options?.typeComment,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const intersection = <const Schemas extends readonly (ObjectSchema<any> | UnionSchema<ObjectSchema<any>[]>)[]>(
|
|
202
|
+
schemas: Schemas,
|
|
203
|
+
options?: Documentation,
|
|
204
|
+
): IntersectionSchema<Schemas> => ({
|
|
205
|
+
type: 'intersection',
|
|
206
|
+
schemas,
|
|
207
|
+
typeName: options?.typeName,
|
|
208
|
+
typeComment: options?.typeComment,
|
|
143
209
|
})
|
|
144
210
|
|
|
145
|
-
const optional = <S extends Schema>(schema: S) =>
|
|
211
|
+
const optional = <S extends Schema>(schema: S, options?: Documentation): OptionalSchema<S> => ({
|
|
212
|
+
type: 'optional',
|
|
213
|
+
schema,
|
|
214
|
+
typeName: options?.typeName,
|
|
215
|
+
typeComment: options?.typeComment,
|
|
216
|
+
})
|
|
146
217
|
|
|
147
218
|
const literal = <Value extends string | number | boolean | bigint>(value: Value): LiteralSchema<Value> => ({
|
|
148
219
|
type: 'literal',
|
|
@@ -171,6 +242,7 @@ export {
|
|
|
171
242
|
record,
|
|
172
243
|
object,
|
|
173
244
|
union,
|
|
245
|
+
intersection,
|
|
174
246
|
optional,
|
|
175
247
|
literal,
|
|
176
248
|
lazy,
|
|
@@ -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
|
+
}
|