Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
98e5a48
NON-WORKING checkpoint towards supporting type inference for schema g…
mathpirate Sep 26, 2025
60a57ad
merge work that was accidentally done on main
mathpirate Sep 29, 2025
a4e7ba6
mostly working checkpoint for inferring types and autogenerating sche…
mathpirate Sep 29, 2025
6573144
new fixtures for new derive and handler type inference
mathpirate Sep 29, 2025
2769583
remove unnneeded fallback, add two new fixture pairs
mathpirate Sep 29, 2025
7a09fb9
refactor type inference to shared utility file
mathpirate Sep 29, 2025
19a2500
based on robin's feedback, generate 'true' for 'any' and 'false' for …
mathpirate Sep 29, 2025
ff71730
fmt
mathpirate Sep 29, 2025
164d620
fix small type inference bug using ben's logCharmsList example; add f…
mathpirate Sep 29, 2025
9cbef04
fmt
mathpirate Sep 29, 2025
01b0bcc
fix for previous overly broad change to how we handle synthetic typen…
mathpirate Sep 29, 2025
a18533d
fix for circular reference bug in generated schemas for Default objects
mathpirate Sep 29, 2025
e052431
fmt
mathpirate Sep 29, 2025
d3c4a90
change/fix behaviors for some boolean schemas and update fixtures acc…
mathpirate Sep 30, 2025
cda3c30
add fixture to validate fix for Default schema bug berni found
mathpirate Sep 30, 2025
644b1ce
temporary commit of type inference findings; will remove later after …
mathpirate Sep 30, 2025
676e906
some type inference findings, and, prepare to update json schema vers…
mathpirate Sep 30, 2025
c879e7d
fmt
mathpirate Sep 30, 2025
fb2408d
update fixtures for new schema version
mathpirate Sep 30, 2025
4a56b01
use instead of definitions
mathpirate Sep 30, 2025
d54af9b
simplify typeregistry to a type alias for weakmap rather than class
mathpirate Sep 30, 2025
038bf63
clean ups
mathpirate Sep 30, 2025
12827cb
correctly inject schemas on/transform derive calls inside JSX
mathpirate Sep 30, 2025
8691969
change format for boolean schemas
mathpirate Sep 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,12 +452,12 @@ export type DeriveFunction = {
input: Opaque<SchemaWithoutCell<InputSchema>>,
f: (
input: Schema<InputSchema>,
) => Schema<ResultSchema> | Promise<Schema<ResultSchema>>,
) => Schema<ResultSchema>,
): OpaqueRef<SchemaWithoutCell<ResultSchema>>;

<In, Out>(
input: Opaque<In>,
f: (input: In) => Out | Promise<Out>,
f: (input: In) => Out,
): OpaqueRef<Out>;
};

Expand Down
10 changes: 5 additions & 5 deletions packages/runner/src/builder/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,30 +186,30 @@ export function derive<
input: Opaque<SchemaWithoutCell<InputSchema>>,
f: (
input: Schema<InputSchema>,
) => Schema<ResultSchema> | Promise<Schema<ResultSchema>>,
) => Schema<ResultSchema>,
): OpaqueRef<SchemaWithoutCell<ResultSchema>>;
export function derive<In, Out>(
input: Opaque<In>,
f: (input: In) => Out | Promise<Out>,
f: (input: In) => Out,
): OpaqueRef<Out>;
export function derive<In, Out>(...args: any[]): OpaqueRef<any> {
if (args.length === 4) {
const [argumentSchema, resultSchema, input, f] = args as [
JSONSchema,
JSONSchema,
Opaque<SchemaWithoutCell<any>>,
(input: Schema<any>) => Schema<any> | Promise<Schema<any>>,
(input: Schema<any>) => Schema<any>,
];
return lift(
argumentSchema,
resultSchema,
f as (input: Schema<any>) => Schema<any> | Promise<Schema<any>>,
f as (input: Schema<any>) => Schema<any>,
)(input);
}

const [input, f] = args as [
Opaque<In>,
(input: In) => Out | Promise<Out>,
(input: In) => Out,
];
return lift(f)(input);
}
Expand Down
13 changes: 8 additions & 5 deletions packages/schema-generator/src/formatters/array-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ export class ArrayFormatter implements TypeFormatter {
);
}

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

if (
(elementFlags & ts.TypeFlags.Any) || (elementFlags & ts.TypeFlags.Unknown)
) {
// any[] or unknown[] - allow any item type
if (elementFlags & ts.TypeFlags.Any) {
// any[] - allow any item type
return { type: "array", items: true };
}

if (elementFlags & ts.TypeFlags.Unknown) {
// unknown[] - allow any item type (type safety at compile time)
return { type: "array", items: true };
}

Expand Down
26 changes: 20 additions & 6 deletions packages/schema-generator/src/formatters/common-tools-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,17 @@ export class CommonToolsFormatter implements TypeFormatter {
);
}

// Prepare inner context (preserve node when available)
const innerContext = innerTypeNode
? { ...context, typeNode: innerTypeNode }
: context;
// Don't pass synthetic TypeNodes - they lose type information (especially for arrays)
// Synthetic nodes have pos === -1 and end === -1
// But DO pass real TypeNodes from source code for proper type detection (e.g., Default)
const isSyntheticNode = innerTypeNode && innerTypeNode.pos === -1 &&
innerTypeNode.end === -1;

const shouldPassTypeNode = innerTypeNode && !isSyntheticNode;
const innerSchema = this.schemaGenerator.formatChildType(
innerType,
innerContext,
innerTypeNode,
context,
shouldPassTypeNode ? innerTypeNode : undefined,
);

// Stream<T>: do not reflect inner Cell-ness; only mark asStream
Expand Down Expand Up @@ -243,7 +246,18 @@ export class CommonToolsFormatter implements TypeFormatter {
defaultTypeNode,
context,
);

if (defaultValue !== undefined) {
// JSON Schema Draft 2020-12 allows default as a sibling of $ref
// Simply add the default property directly to the schema
if (typeof valueSchema === "boolean") {
// Boolean schemas (true/false) cannot have properties directly
// For true: { default: value } (any value is valid)
// For false: { not: true, default: value } (no value is valid)
return valueSchema === false
? { not: true, default: defaultValue } as SchemaDefinition
: { default: defaultValue } as SchemaDefinition;
}
(valueSchema as any).default = defaultValue;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export class IntersectionFormatter implements TypeFormatter {
typeof (schema as Record<string, unknown>).$ref === "string"
) {
const ref = (schema as Record<string, unknown>).$ref as string;
const prefix = "#/definitions/";
const prefix = "#/$defs/";
if (ref.startsWith(prefix)) {
const name = ref.slice(prefix.length);
const def = context.definitions[name];
Expand Down
26 changes: 17 additions & 9 deletions packages/schema-generator/src/formatters/primitive-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,28 @@ export class PrimitiveFormatter implements TypeFormatter {
return { type: "null" };
}
if (flags & ts.TypeFlags.Undefined) {
// undefined cannot occur in JSON - return {} which matches anything
return {};
// undefined: return true to indicate "accept any value"
// undefined is handled at runtime/compile time, not by JSON schema validation
return true;
}
if (flags & ts.TypeFlags.Void) {
// void cannot occur in JSON - return {} which matches anything
return {};
// void: return true to indicate "accept any value"
// void functions don't return meaningful values, so schema validation is permissive
return true;
}
if (flags & ts.TypeFlags.Never) {
// never cannot occur in JSON - return {} which matches anything
return {};
// never: return false to reject all values
// never means this type can never occur, so no value should validate
return false;
}
if ((flags & ts.TypeFlags.Unknown) || (flags & ts.TypeFlags.Any)) {
// unknown/any can be any JSON value (primitive or object) - {} matches everything
return {};
if (flags & ts.TypeFlags.Any) {
// any: return true to indicate "allow any value"
return true;
}
if (flags & ts.TypeFlags.Unknown) {
// unknown: return true to indicate "accept any value"
// Type safety is enforced at compile time via TypeScript narrowing
return true;
}

// Fallback
Expand Down
32 changes: 17 additions & 15 deletions packages/schema-generator/src/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class SchemaGenerator implements ISchemaGenerator {
// Hoist every named type (excluding wrappers filtered by getNamedTypeKey)
// into definitions and return $ref for non-root uses. Cycle detection
// still applies via definitionStack.
let namedKey = getNamedTypeKey(type);
let namedKey = getNamedTypeKey(type, context.typeNode);
if (!namedKey) {
const synthetic = context.anonymousNames.get(type);
if (synthetic) namedKey = synthetic;
Expand All @@ -170,7 +170,7 @@ export class SchemaGenerator implements ISchemaGenerator {
context.definitionStack.delete(
this.createStackKey(type, context.typeNode, context.typeChecker),
);
return { "$ref": `#/definitions/${namedKey}` };
return { "$ref": `#/$defs/${namedKey}` };
}
// Start building this named type; we'll store the result below
context.inProgressNames.add(namedKey);
Expand All @@ -185,12 +185,12 @@ export class SchemaGenerator implements ISchemaGenerator {
if (context.definitionStack.has(stackKey)) {
if (namedKey) {
context.emittedRefs.add(namedKey);
return { "$ref": `#/definitions/${namedKey}` };
return { "$ref": `#/$defs/${namedKey}` };
}
const syntheticKey = this.ensureSyntheticName(type, context);
context.inProgressNames.add(syntheticKey);
context.emittedRefs.add(syntheticKey);
return { "$ref": `#/definitions/${syntheticKey}` };
return { "$ref": `#/$defs/${syntheticKey}` };
}

// Push current type onto the stack
Expand All @@ -213,7 +213,7 @@ export class SchemaGenerator implements ISchemaGenerator {
);
if (!isRootType) {
context.emittedRefs.add(keyForDef);
return { "$ref": `#/definitions/${keyForDef}` };
return { "$ref": `#/$defs/${keyForDef}` };
}
// For root, keep inline; buildFinalSchema may promote if we choose
}
Expand Down Expand Up @@ -267,27 +267,29 @@ export class SchemaGenerator implements ISchemaGenerator {
if (!definitions[namedKey]) {
definitions[namedKey] = rootSchema;
}
base = { $ref: `#/definitions/${namedKey}` } as SchemaDefinition;
base = { $ref: `#/$defs/${namedKey}` } as SchemaDefinition;
} else {
base = rootSchema;
}

// Handle boolean schemas (rare, but supported by JSON Schema)
if (typeof base === "boolean") {
return base ? { $schema: "https://json-schema.org/draft-07/schema#" } : {
$schema: "https://json-schema.org/draft-07/schema#",
not: true,
};
return base
? { $schema: "https://json-schema.org/draft/2020-12/schema" }
: {
$schema: "https://json-schema.org/draft/2020-12/schema",
not: true,
};
}

// Object schema: attach only the definitions actually referenced by the
// final output
const filtered = this.collectReferencedDefinitions(base, definitions);
const out: Record<string, unknown> = {
$schema: "https://json-schema.org/draft-07/schema#",
$schema: "https://json-schema.org/draft/2020-12/schema",
...(base as Record<string, unknown>),
};
if (Object.keys(filtered).length > 0) out.definitions = filtered;
if (Object.keys(filtered).length > 0) out.$defs = filtered;
return out as SchemaDefinition;
}

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

const enqueueFromRef = (ref: string) => {
const prefix = "#/definitions/";
const prefix = "#/$defs/";
if (typeof ref === "string" && ref.startsWith(prefix)) {
const name = ref.slice(prefix.length);
if (name) needed.add(name);
Expand All @@ -430,9 +432,9 @@ export class SchemaGenerator implements ISchemaGenerator {
const obj = node as Record<string, unknown>;
for (const [k, v] of Object.entries(obj)) {
if (k === "$ref" && typeof v === "string") enqueueFromRef(v);
// Skip descending into existing definitions blocks to avoid pulling in
// Skip descending into existing $defs blocks to avoid pulling in
// already-attached subsets recursively
if (k === "definitions") continue;
if (k === "$defs" || k === "definitions") continue;
scan(v);
}
};
Expand Down
35 changes: 30 additions & 5 deletions packages/schema-generator/src/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,31 @@ export function getNativeTypeSchema(
*/
export function getNamedTypeKey(
type: ts.Type,
typeNode?: ts.TypeNode,
): string | undefined {
// Check if the TypeNode indicates this is a wrapper type (Default/Cell/Stream)
// Even if the type symbol says it's the inner type, if it's wrapped we shouldn't hoist it
if (
typeNode && ts.isTypeReferenceNode(typeNode) &&
ts.isIdentifier(typeNode.typeName)
) {
const nodeTypeName = typeNode.typeName.text;
if (
nodeTypeName === "Default" || nodeTypeName === "Cell" ||
nodeTypeName === "Stream"
) {
return undefined;
}
}

// Check if this is a Default/Cell/Stream wrapper type via alias
const aliasName = (type as TypeWithInternals).aliasSymbol?.name;
if (
aliasName === "Default" || aliasName === "Cell" || aliasName === "Stream"
) {
return undefined;
}

// Prefer direct symbol name; fall back to target symbol for TypeReference
const symbol = type.symbol;
let name = symbol?.name;
Expand All @@ -220,12 +244,13 @@ export function getNamedTypeKey(
const ref = type as unknown as ts.TypeReference;
name = ref.target?.symbol?.name ?? name;
}
// Fall back to alias symbol when present (type aliases)
if (!name) {
const aliasName = (type as TypeWithInternals).aliasSymbol?.name;
if (aliasName) name = aliasName;
// Fall back to alias symbol when present (type aliases) if we haven't used it yet
if (!name && aliasName) {
name = aliasName;
}
if (!name || name === "__type") {
return undefined;
}
if (!name || name === "__type") return undefined;
// Exclude property/method-like symbols (member names), which are not real named types
const symFlags = symbol?.flags ?? 0;
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"$ref": "#/definitions/SchemaRoot",
"definitions": {
"$ref": "#/$defs/SchemaRoot",
"$defs": {
"B": {
"properties": {
"c": {
"$ref": "#/definitions/C"
"$ref": "#/$defs/C"
}
},
"required": [
Expand All @@ -15,7 +15,7 @@
"C": {
"properties": {
"a": {
"$ref": "#/definitions/SchemaRoot"
"$ref": "#/$defs/SchemaRoot"
}
},
"required": [
Expand All @@ -26,7 +26,7 @@
"SchemaRoot": {
"properties": {
"b": {
"$ref": "#/definitions/B"
"$ref": "#/$defs/B"
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"$ref": "#/definitions/SchemaRoot",
"definitions": {
"$ref": "#/$defs/SchemaRoot",
"$defs": {
"SchemaRoot": {
"properties": {
"a": {
"$ref": "#/definitions/SchemaRootA"
"$ref": "#/$defs/SchemaRootA"
}
},
"type": "object"
},
"SchemaRootA": {
"properties": {
"b": {
"$ref": "#/definitions/SchemaRoot"
"$ref": "#/$defs/SchemaRoot"
}
},
"type": "object"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"definitions": {
"$defs": {
"Document": {
"properties": {
"archivedAt": {
Expand Down Expand Up @@ -96,13 +96,13 @@
},
"properties": {
"document": {
"$ref": "#/definitions/Document"
"$ref": "#/$defs/Document"
},
"eventLog": {
"$ref": "#/definitions/EventLog"
"$ref": "#/$defs/EventLog"
},
"userProfile": {
"$ref": "#/definitions/UserProfile"
"$ref": "#/$defs/UserProfile"
}
},
"required": [
Expand Down
Loading