Compare commits

...
Author SHA1 Message Date
ctate 1f855b5a0f fix: document record type limitation and add anyOf nullable test
- Add clear JSDoc documenting that record/map types become opaque objects
  in strict mode (LLM strict schemas require additionalProperties: false)
- Improve inline comment on the record case in zodToJsonSchema
- Add test exercising the anyOf nullable wrapping for optional properties
  using a non-record schema so the path is directly verified
2026-03-10 03:40:15 +00:00
Chris Tate 0d51a194bc feat: add strict mode to jsonSchema() for LLM structured outputs
This PR adds support for generating strict JSON schemas compatible with LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).

## Problem
`catalog.jsonSchema()` was generating schemas that OpenAI's strict mode rejected due to:
- Use of `propertyNames` keyword (not permitted)
- Missing `additionalProperties: false` on nested objects
- Optional properties not being handled correctly for strict validation

## Changes
- Added `JsonSchemaOptions` interface with `strict` boolean flag
- Updated `catalog.jsonSchema()` to accept optional `JsonSchemaOptions` parameter
- When `strict: true`, the schema generator:
  - Sets `additionalProperties: false` on all object types
  - Removes `propertyNames` constraints
  - Lists all properties (including optional ones) in `required` arrays, using nullable types for optionals
  - Converts record types to fixed-key objects without additional property schemas
- Fixed Zod 4 compatibility issue in type name resolution
- Added comprehensive test suite covering all strict mode requirements
- Exported `JsonSchemaOptions` type from core package

## Usage
```typescript
// Default behavior (unchanged)
const schema = catalog.jsonSchema();

// Strict mode for LLM APIs
const strictSchema = catalog.jsonSchema({ strict: true });
```

The default behavior remains unchanged to maintain backward compatibility.

Fixes #180
2026-03-09 21:45:04 -05:00
3 changed files with 336 additions and 34 deletions
+1
View File
@@ -160,6 +160,7 @@ export { defineSchema } from "./schema";
// Catalog — defines the vocabulary (what components and actions are available)
export type {
Catalog,
JsonSchemaOptions,
PromptOptions,
PromptContext,
SpecValidationResult,
+188
View File
@@ -560,6 +560,194 @@ describe("catalog.jsonSchema", () => {
expect(jsonSchema).not.toBeNull();
expect(typeof jsonSchema).toBe("object");
});
describe("strict mode (LLM structured output compatible)", () => {
function hasNoPropertyNames(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
if ("propertyNames" in obj) return false;
return Object.values(obj).every(hasNoPropertyNames);
}
function allObjectsHaveAdditionalPropertiesFalse(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
const record = obj as Record<string, unknown>;
if (record.type === "object") {
if (record.additionalProperties !== false) return false;
}
return Object.values(record).every(
allObjectsHaveAdditionalPropertiesFalse,
);
}
function allObjectPropertiesRequired(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
const record = obj as Record<string, unknown>;
if (
record.type === "object" &&
record.properties &&
typeof record.properties === "object"
) {
const propKeys = Object.keys(record.properties);
const required = (record.required as string[]) ?? [];
if (!propKeys.every((k) => required.includes(k))) return false;
}
return Object.values(record).every(allObjectPropertiesRequired);
}
it("sets additionalProperties: false on all nested objects", () => {
const catalog = defineCatalog(testSchema, {
components: {
Card: {
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({ strict: true });
expect(allObjectsHaveAdditionalPropertiesFalse(schema)).toBe(true);
});
it("does not emit propertyNames", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({ strict: true });
expect(hasNoPropertyNames(schema)).toBe(true);
});
it("lists all properties in required (optional uses nullable)", () => {
const catalog = defineCatalog(testSchema, {
components: {
Card: {
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({ strict: true });
expect(allObjectPropertiesRequired(schema)).toBe(true);
});
it("converts record types without additionalProperties schema value", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const schema = catalog.jsonSchema({
strict: true,
}) as Record<string, unknown>;
// Walk the schema and ensure no additionalProperties is set to a non-false value
function noAdditionalPropertiesSchema(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) return true;
const rec = obj as Record<string, unknown>;
if (
"additionalProperties" in rec &&
rec.additionalProperties !== false
) {
return false;
}
return Object.values(rec).every(noAdditionalPropertiesSchema);
}
expect(noAdditionalPropertiesSchema(schema)).toBe(true);
});
it("wraps optional properties with anyOf nullable", () => {
// Use a schema where propsOf resolves to a single component's props
// (no record wrapper around the props) so the optional anyOf handling
// is directly visible in the JSON Schema output.
const flatSchema = defineSchema((s) => ({
spec: s.object({
component: s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
}),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
description: s.string(),
}),
}),
}));
const catalog = defineCatalog(flatSchema, {
components: {
Card: {
props: z.object({
heading: z.string(),
caption: z.string().optional(),
}),
description: "",
},
},
});
const schema = catalog.jsonSchema({ strict: true }) as {
properties: {
component: {
properties: { props: Record<string, unknown> };
};
};
};
const propsSchema = schema.properties.component.properties.props;
// caption is optional – in strict mode it must be in `required`
// and wrapped in anyOf with null
const captionSchema = (
propsSchema as {
properties: { caption: Record<string, unknown> };
}
).properties.caption;
expect(captionSchema).toEqual({
anyOf: [{ type: "string" }, { type: "null" }],
});
const propsRequired = (propsSchema as { required: string[] }).required;
expect(propsRequired).toContain("heading");
expect(propsRequired).toContain("caption");
});
it("does not affect default (non-strict) output", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const defaultSchema = catalog.jsonSchema();
const defaultSchema2 = catalog.jsonSchema({ strict: false });
expect(defaultSchema).toEqual(defaultSchema2);
});
});
});
// =============================================================================
+147 -34
View File
@@ -92,7 +92,7 @@ export interface Catalog<
/** Generate system prompt for AI */
prompt(options?: PromptOptions): string;
/** Export as JSON Schema for structured outputs */
jsonSchema(): object;
jsonSchema(options?: JsonSchemaOptions): object;
/** Validate a spec against this catalog */
validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>>;
/** Get the Zod schema for the spec */
@@ -101,6 +101,28 @@ export interface Catalog<
readonly _specType: InferSpec<TDef, TCatalog>;
}
/**
* Options for JSON Schema export
*/
export interface JsonSchemaOptions {
/**
* When true, produces a strict JSON Schema subset compatible with
* LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).
* This ensures:
* - `additionalProperties: false` on every object
* - All object properties listed in `required` (optionals use nullable types)
* - Record/map types converted to fixed-key objects
*
* **Limitation:** Record types (dynamic-key maps) cannot be represented in
* strict JSON Schema because `additionalProperties` must be `false`. They
* are emitted as `{ type: "object", properties: {}, additionalProperties: false }`.
* The LLM prompt (via `catalog.prompt()`) still describes the full structure,
* so the model can produce valid output even though the JSON Schema for
* record entries is opaque.
*/
strict?: boolean;
}
/**
* Prompt generation options
*/
@@ -411,8 +433,8 @@ function createCatalogFromSchema<TDef extends SchemaDefinition, TCatalog>(
return generatePrompt(this, options);
},
jsonSchema(): object {
return zodToJsonSchema(zodSchema);
jsonSchema(options: JsonSchemaOptions = {}): object {
return zodToJsonSchema(zodSchema, options.strict ?? false);
},
validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>> {
@@ -1303,44 +1325,111 @@ function formatZodType(schema: z.ZodType): string {
}
/**
* Convert Zod schema to JSON Schema
* Resolve the Zod type name from a schema's internal definition.
* Supports both Zod 3 (`_def.typeName`) and Zod 4 (`_def.type`).
*/
function zodToJsonSchema(schema: z.ZodType): object {
// Simplified JSON Schema conversion
const def = schema._def as unknown as Record<string, unknown>;
const typeName = (def.typeName as string) ?? "";
function zodTypeName(def: Record<string, unknown>): string {
// Zod 4 uses _def.type as a plain string (e.g. "string", "object")
if (typeof def.type === "string") return def.type;
// Zod 3 uses _def.typeName (e.g. "ZodString", "ZodObject")
if (typeof def.typeName === "string") return def.typeName;
return "";
}
switch (typeName) {
case "ZodString":
/**
* Normalise a Zod type name to a canonical lowercase form.
* Handles both Zod 3 ("ZodString") and Zod 4 ("string") conventions.
*/
function normalizeTypeName(raw: string): string {
// Zod 3 names start with "Zod", e.g. "ZodString" → "string"
if (raw.startsWith("Zod")) {
return raw.slice(3).toLowerCase();
}
return raw.toLowerCase();
}
/**
* Convert Zod schema to JSON Schema.
*
* When `strict` is true the output conforms to the JSON Schema subset required
* by LLM structured output APIs (no `propertyNames`, `additionalProperties: false`
* everywhere, all properties listed in `required`).
*/
function zodToJsonSchema(schema: z.ZodType, strict = false): object {
const def = schema._def as unknown as Record<string, unknown>;
const kind = normalizeTypeName(zodTypeName(def));
switch (kind) {
case "string":
return { type: "string" };
case "ZodNumber":
case "number":
return { type: "number" };
case "ZodBoolean":
case "boolean":
return { type: "boolean" };
case "ZodLiteral":
return { const: def.value };
case "ZodEnum":
return { enum: def.values };
case "ZodArray": {
const inner = def.type as z.ZodType | undefined;
case "literal": {
// Zod 4: _def.values (array), Zod 3: _def.value (single)
const values = def.values as unknown[] | undefined;
const value = values ? values[0] : def.value;
return { const: value };
}
case "enum": {
// Zod 4: _def.entries (object { a:"a", b:"b" }), Zod 3: _def.values (string[])
const entries = def.entries as Record<string, string> | undefined;
const values = entries
? Object.values(entries)
: (def.values as string[] | undefined);
return { enum: values ?? [] };
}
case "array": {
// Zod 4: _def.element, Zod 3: _def.type
const inner = (def.element ?? def.type) as z.ZodType | undefined;
return {
type: "array",
items: inner ? zodToJsonSchema(inner) : {},
items: inner ? zodToJsonSchema(inner, strict) : {},
};
}
case "ZodObject": {
const shape = (def.shape as () => Record<string, z.ZodType>)?.();
if (!shape) return { type: "object" };
case "object": {
// Zod 4: _def.shape is an object, Zod 3: _def.shape is a function
const rawShape = def.shape;
const shape: Record<string, z.ZodType> | undefined =
typeof rawShape === "function"
? (rawShape as () => Record<string, z.ZodType>)()
: (rawShape as Record<string, z.ZodType> | undefined);
if (!shape) {
if (strict) {
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
return { type: "object" };
}
const properties: Record<string, object> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
properties[key] = zodToJsonSchema(value);
const innerDef = value._def as unknown as Record<string, unknown>;
if (
innerDef.typeName !== "ZodOptional" &&
innerDef.typeName !== "ZodNullable"
) {
const innerKind = normalizeTypeName(zodTypeName(innerDef));
const isOptional = innerKind === "optional" || innerKind === "nullable";
if (strict) {
// In strict mode, all properties must be in required.
// Optional properties are represented as nullable types.
required.push(key);
if (isOptional) {
const unwrapped = zodToJsonSchema(value, strict);
properties[key] = { anyOf: [unwrapped, { type: "null" }] };
} else {
properties[key] = zodToJsonSchema(value, strict);
}
} else {
properties[key] = zodToJsonSchema(value);
if (!isOptional) {
required.push(key);
}
}
}
return {
@@ -1350,23 +1439,47 @@ function zodToJsonSchema(schema: z.ZodType): object {
additionalProperties: false,
};
}
case "ZodRecord": {
case "record": {
const valueType = def.valueType as z.ZodType | undefined;
if (strict) {
// LLM strict schemas require `additionalProperties: false` and do not
// permit a schema value for `additionalProperties`. Since record types
// have dynamic keys that cannot be enumerated at schema-generation time,
// we emit an opaque object. The LLM prompt still describes the expected
// structure so the model can produce valid output.
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
return {
type: "object",
additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
};
}
case "ZodOptional":
case "ZodNullable": {
case "optional":
case "nullable": {
const inner = def.innerType as z.ZodType | undefined;
return inner ? zodToJsonSchema(inner) : {};
return inner ? zodToJsonSchema(inner, strict) : {};
}
case "ZodUnion": {
case "union": {
const options = def.options as z.ZodType[] | undefined;
return options ? { anyOf: options.map(zodToJsonSchema) } : {};
return options
? { anyOf: options.map((o) => zodToJsonSchema(o, strict)) }
: {};
}
case "ZodAny":
case "any":
case "unknown":
if (strict) {
return {
type: "object",
properties: {},
required: [],
additionalProperties: false,
};
}
return {};
default:
return {};