Skip to content

Commit a3b4717

Browse files
authored
Updates to derive and lift (#1838)
* Support automatic schema injection for `derive` and `lift` overloads * Remove Promise type branch for `derive` * Based on robin's feedback, generate 'true' for 'any' and 'false' for 'unknown' * Fix for circular reference bug in generated schemas for Default objects * Bump JSON Schema version; use $defs instead of 'definitions'; allow 'default' properties as siblings of $refs * Correctly inject schemas on/transform derive calls inside JSX
1 parent c9c33c7 commit a3b4717

File tree

83 files changed

+2510
-243
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

83 files changed

+2510
-243
lines changed

packages/api/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,12 @@ export type DeriveFunction = {
452452
input: Opaque<SchemaWithoutCell<InputSchema>>,
453453
f: (
454454
input: Schema<InputSchema>,
455-
) => Schema<ResultSchema> | Promise<Schema<ResultSchema>>,
455+
) => Schema<ResultSchema>,
456456
): OpaqueRef<SchemaWithoutCell<ResultSchema>>;
457457

458458
<In, Out>(
459459
input: Opaque<In>,
460-
f: (input: In) => Out | Promise<Out>,
460+
f: (input: In) => Out,
461461
): OpaqueRef<Out>;
462462
};
463463

packages/runner/src/builder/module.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,30 +186,30 @@ export function derive<
186186
input: Opaque<SchemaWithoutCell<InputSchema>>,
187187
f: (
188188
input: Schema<InputSchema>,
189-
) => Schema<ResultSchema> | Promise<Schema<ResultSchema>>,
189+
) => Schema<ResultSchema>,
190190
): OpaqueRef<SchemaWithoutCell<ResultSchema>>;
191191
export function derive<In, Out>(
192192
input: Opaque<In>,
193-
f: (input: In) => Out | Promise<Out>,
193+
f: (input: In) => Out,
194194
): OpaqueRef<Out>;
195195
export function derive<In, Out>(...args: any[]): OpaqueRef<any> {
196196
if (args.length === 4) {
197197
const [argumentSchema, resultSchema, input, f] = args as [
198198
JSONSchema,
199199
JSONSchema,
200200
Opaque<SchemaWithoutCell<any>>,
201-
(input: Schema<any>) => Schema<any> | Promise<Schema<any>>,
201+
(input: Schema<any>) => Schema<any>,
202202
];
203203
return lift(
204204
argumentSchema,
205205
resultSchema,
206-
f as (input: Schema<any>) => Schema<any> | Promise<Schema<any>>,
206+
f as (input: Schema<any>) => Schema<any>,
207207
)(input);
208208
}
209209

210210
const [input, f] = args as [
211211
Opaque<In>,
212-
(input: In) => Out | Promise<Out>,
212+
(input: In) => Out,
213213
];
214214
return lift(f)(input);
215215
}

packages/schema-generator/src/formatters/array-formatter.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ export class ArrayFormatter implements TypeFormatter {
3333
);
3434
}
3535

36-
// Handle special cases for any[] and never[] with JSON Schema shortcuts
36+
// Handle special cases for any[], unknown[], and never[] with JSON Schema shortcuts
3737
const elementFlags = info.elementType.flags;
3838

39-
if (
40-
(elementFlags & ts.TypeFlags.Any) || (elementFlags & ts.TypeFlags.Unknown)
41-
) {
42-
// any[] or unknown[] - allow any item type
39+
if (elementFlags & ts.TypeFlags.Any) {
40+
// any[] - allow any item type
41+
return { type: "array", items: true };
42+
}
43+
44+
if (elementFlags & ts.TypeFlags.Unknown) {
45+
// unknown[] - allow any item type (type safety at compile time)
4346
return { type: "array", items: true };
4447
}
4548

packages/schema-generator/src/formatters/common-tools-formatter.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,17 @@ export class CommonToolsFormatter implements TypeFormatter {
109109
);
110110
}
111111

112-
// Prepare inner context (preserve node when available)
113-
const innerContext = innerTypeNode
114-
? { ...context, typeNode: innerTypeNode }
115-
: context;
112+
// Don't pass synthetic TypeNodes - they lose type information (especially for arrays)
113+
// Synthetic nodes have pos === -1 and end === -1
114+
// But DO pass real TypeNodes from source code for proper type detection (e.g., Default)
115+
const isSyntheticNode = innerTypeNode && innerTypeNode.pos === -1 &&
116+
innerTypeNode.end === -1;
117+
118+
const shouldPassTypeNode = innerTypeNode && !isSyntheticNode;
116119
const innerSchema = this.schemaGenerator.formatChildType(
117120
innerType,
118-
innerContext,
119-
innerTypeNode,
121+
context,
122+
shouldPassTypeNode ? innerTypeNode : undefined,
120123
);
121124

122125
// Stream<T>: do not reflect inner Cell-ness; only mark asStream
@@ -243,7 +246,18 @@ export class CommonToolsFormatter implements TypeFormatter {
243246
defaultTypeNode,
244247
context,
245248
);
249+
246250
if (defaultValue !== undefined) {
251+
// JSON Schema Draft 2020-12 allows default as a sibling of $ref
252+
// Simply add the default property directly to the schema
253+
if (typeof valueSchema === "boolean") {
254+
// Boolean schemas (true/false) cannot have properties directly
255+
// For true: { default: value } (any value is valid)
256+
// For false: { not: true, default: value } (no value is valid)
257+
return valueSchema === false
258+
? { not: true, default: defaultValue } as SchemaDefinition
259+
: { default: defaultValue } as SchemaDefinition;
260+
}
247261
(valueSchema as any).default = defaultValue;
248262
}
249263

packages/schema-generator/src/formatters/intersection-formatter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export class IntersectionFormatter implements TypeFormatter {
186186
typeof (schema as Record<string, unknown>).$ref === "string"
187187
) {
188188
const ref = (schema as Record<string, unknown>).$ref as string;
189-
const prefix = "#/definitions/";
189+
const prefix = "#/$defs/";
190190
if (ref.startsWith(prefix)) {
191191
const name = ref.slice(prefix.length);
192192
const def = context.definitions[name];

packages/schema-generator/src/formatters/primitive-formatter.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,28 @@ export class PrimitiveFormatter implements TypeFormatter {
6464
return { type: "null" };
6565
}
6666
if (flags & ts.TypeFlags.Undefined) {
67-
// undefined cannot occur in JSON - return {} which matches anything
68-
return {};
67+
// undefined: return true to indicate "accept any value"
68+
// undefined is handled at runtime/compile time, not by JSON schema validation
69+
return true;
6970
}
7071
if (flags & ts.TypeFlags.Void) {
71-
// void cannot occur in JSON - return {} which matches anything
72-
return {};
72+
// void: return true to indicate "accept any value"
73+
// void functions don't return meaningful values, so schema validation is permissive
74+
return true;
7375
}
7476
if (flags & ts.TypeFlags.Never) {
75-
// never cannot occur in JSON - return {} which matches anything
76-
return {};
77+
// never: return false to reject all values
78+
// never means this type can never occur, so no value should validate
79+
return false;
7780
}
78-
if ((flags & ts.TypeFlags.Unknown) || (flags & ts.TypeFlags.Any)) {
79-
// unknown/any can be any JSON value (primitive or object) - {} matches everything
80-
return {};
81+
if (flags & ts.TypeFlags.Any) {
82+
// any: return true to indicate "allow any value"
83+
return true;
84+
}
85+
if (flags & ts.TypeFlags.Unknown) {
86+
// unknown: return true to indicate "accept any value"
87+
// Type safety is enforced at compile time via TypeScript narrowing
88+
return true;
8189
}
8290

8391
// Fallback

packages/schema-generator/src/schema-generator.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export class SchemaGenerator implements ISchemaGenerator {
156156
// Hoist every named type (excluding wrappers filtered by getNamedTypeKey)
157157
// into definitions and return $ref for non-root uses. Cycle detection
158158
// still applies via definitionStack.
159-
let namedKey = getNamedTypeKey(type);
159+
let namedKey = getNamedTypeKey(type, context.typeNode);
160160
if (!namedKey) {
161161
const synthetic = context.anonymousNames.get(type);
162162
if (synthetic) namedKey = synthetic;
@@ -170,7 +170,7 @@ export class SchemaGenerator implements ISchemaGenerator {
170170
context.definitionStack.delete(
171171
this.createStackKey(type, context.typeNode, context.typeChecker),
172172
);
173-
return { "$ref": `#/definitions/${namedKey}` };
173+
return { "$ref": `#/$defs/${namedKey}` };
174174
}
175175
// Start building this named type; we'll store the result below
176176
context.inProgressNames.add(namedKey);
@@ -185,12 +185,12 @@ export class SchemaGenerator implements ISchemaGenerator {
185185
if (context.definitionStack.has(stackKey)) {
186186
if (namedKey) {
187187
context.emittedRefs.add(namedKey);
188-
return { "$ref": `#/definitions/${namedKey}` };
188+
return { "$ref": `#/$defs/${namedKey}` };
189189
}
190190
const syntheticKey = this.ensureSyntheticName(type, context);
191191
context.inProgressNames.add(syntheticKey);
192192
context.emittedRefs.add(syntheticKey);
193-
return { "$ref": `#/definitions/${syntheticKey}` };
193+
return { "$ref": `#/$defs/${syntheticKey}` };
194194
}
195195

196196
// Push current type onto the stack
@@ -213,7 +213,7 @@ export class SchemaGenerator implements ISchemaGenerator {
213213
);
214214
if (!isRootType) {
215215
context.emittedRefs.add(keyForDef);
216-
return { "$ref": `#/definitions/${keyForDef}` };
216+
return { "$ref": `#/$defs/${keyForDef}` };
217217
}
218218
// For root, keep inline; buildFinalSchema may promote if we choose
219219
}
@@ -267,27 +267,29 @@ export class SchemaGenerator implements ISchemaGenerator {
267267
if (!definitions[namedKey]) {
268268
definitions[namedKey] = rootSchema;
269269
}
270-
base = { $ref: `#/definitions/${namedKey}` } as SchemaDefinition;
270+
base = { $ref: `#/$defs/${namedKey}` } as SchemaDefinition;
271271
} else {
272272
base = rootSchema;
273273
}
274274

275275
// Handle boolean schemas (rare, but supported by JSON Schema)
276276
if (typeof base === "boolean") {
277-
return base ? { $schema: "https://json-schema.org/draft-07/schema#" } : {
278-
$schema: "https://json-schema.org/draft-07/schema#",
279-
not: true,
280-
};
277+
return base
278+
? { $schema: "https://json-schema.org/draft/2020-12/schema" }
279+
: {
280+
$schema: "https://json-schema.org/draft/2020-12/schema",
281+
not: true,
282+
};
281283
}
282284

283285
// Object schema: attach only the definitions actually referenced by the
284286
// final output
285287
const filtered = this.collectReferencedDefinitions(base, definitions);
286288
const out: Record<string, unknown> = {
287-
$schema: "https://json-schema.org/draft-07/schema#",
289+
$schema: "https://json-schema.org/draft/2020-12/schema",
288290
...(base as Record<string, unknown>),
289291
};
290-
if (Object.keys(filtered).length > 0) out.definitions = filtered;
292+
if (Object.keys(filtered).length > 0) out.$defs = filtered;
291293
return out as SchemaDefinition;
292294
}
293295

@@ -414,7 +416,7 @@ export class SchemaGenerator implements ISchemaGenerator {
414416
const visited = new Set<string>();
415417

416418
const enqueueFromRef = (ref: string) => {
417-
const prefix = "#/definitions/";
419+
const prefix = "#/$defs/";
418420
if (typeof ref === "string" && ref.startsWith(prefix)) {
419421
const name = ref.slice(prefix.length);
420422
if (name) needed.add(name);
@@ -430,9 +432,9 @@ export class SchemaGenerator implements ISchemaGenerator {
430432
const obj = node as Record<string, unknown>;
431433
for (const [k, v] of Object.entries(obj)) {
432434
if (k === "$ref" && typeof v === "string") enqueueFromRef(v);
433-
// Skip descending into existing definitions blocks to avoid pulling in
435+
// Skip descending into existing $defs blocks to avoid pulling in
434436
// already-attached subsets recursively
435-
if (k === "definitions") continue;
437+
if (k === "$defs" || k === "definitions") continue;
436438
scan(v);
437439
}
438440
};

packages/schema-generator/src/type-utils.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,31 @@ export function getNativeTypeSchema(
211211
*/
212212
export function getNamedTypeKey(
213213
type: ts.Type,
214+
typeNode?: ts.TypeNode,
214215
): string | undefined {
216+
// Check if the TypeNode indicates this is a wrapper type (Default/Cell/Stream)
217+
// Even if the type symbol says it's the inner type, if it's wrapped we shouldn't hoist it
218+
if (
219+
typeNode && ts.isTypeReferenceNode(typeNode) &&
220+
ts.isIdentifier(typeNode.typeName)
221+
) {
222+
const nodeTypeName = typeNode.typeName.text;
223+
if (
224+
nodeTypeName === "Default" || nodeTypeName === "Cell" ||
225+
nodeTypeName === "Stream"
226+
) {
227+
return undefined;
228+
}
229+
}
230+
231+
// Check if this is a Default/Cell/Stream wrapper type via alias
232+
const aliasName = (type as TypeWithInternals).aliasSymbol?.name;
233+
if (
234+
aliasName === "Default" || aliasName === "Cell" || aliasName === "Stream"
235+
) {
236+
return undefined;
237+
}
238+
215239
// Prefer direct symbol name; fall back to target symbol for TypeReference
216240
const symbol = type.symbol;
217241
let name = symbol?.name;
@@ -220,12 +244,13 @@ export function getNamedTypeKey(
220244
const ref = type as unknown as ts.TypeReference;
221245
name = ref.target?.symbol?.name ?? name;
222246
}
223-
// Fall back to alias symbol when present (type aliases)
224-
if (!name) {
225-
const aliasName = (type as TypeWithInternals).aliasSymbol?.name;
226-
if (aliasName) name = aliasName;
247+
// Fall back to alias symbol when present (type aliases) if we haven't used it yet
248+
if (!name && aliasName) {
249+
name = aliasName;
250+
}
251+
if (!name || name === "__type") {
252+
return undefined;
227253
}
228-
if (!name || name === "__type") return undefined;
229254
// Exclude property/method-like symbols (member names), which are not real named types
230255
const symFlags = symbol?.flags ?? 0;
231256
if (

packages/schema-generator/test/fixtures/schema/cycles-multi-hop.expected.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
2-
"$ref": "#/definitions/SchemaRoot",
3-
"definitions": {
2+
"$ref": "#/$defs/SchemaRoot",
3+
"$defs": {
44
"B": {
55
"properties": {
66
"c": {
7-
"$ref": "#/definitions/C"
7+
"$ref": "#/$defs/C"
88
}
99
},
1010
"required": [
@@ -15,7 +15,7 @@
1515
"C": {
1616
"properties": {
1717
"a": {
18-
"$ref": "#/definitions/SchemaRoot"
18+
"$ref": "#/$defs/SchemaRoot"
1919
}
2020
},
2121
"required": [
@@ -26,7 +26,7 @@
2626
"SchemaRoot": {
2727
"properties": {
2828
"b": {
29-
"$ref": "#/definitions/B"
29+
"$ref": "#/$defs/B"
3030
}
3131
},
3232
"required": [

packages/schema-generator/test/fixtures/schema/cycles-mutual-optional.expected.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
{
2-
"$ref": "#/definitions/SchemaRoot",
3-
"definitions": {
2+
"$ref": "#/$defs/SchemaRoot",
3+
"$defs": {
44
"SchemaRoot": {
55
"properties": {
66
"a": {
7-
"$ref": "#/definitions/SchemaRootA"
7+
"$ref": "#/$defs/SchemaRootA"
88
}
99
},
1010
"type": "object"
1111
},
1212
"SchemaRootA": {
1313
"properties": {
1414
"b": {
15-
"$ref": "#/definitions/SchemaRoot"
15+
"$ref": "#/$defs/SchemaRoot"
1616
}
1717
},
1818
"type": "object"

0 commit comments

Comments
 (0)