Structured Outputs for Codegen: JSON Schemas That Actually Compile

Structured Outputs for Codegen: JSON Schemas That Actually Compile

Free-form markdown from an LLM is a demo. Production codegen needs a contract: JSON Schema → validated object → deterministic template/AST transform → files on disk. If the shape is wrong, you never touch the filesystem.

⚡ TL;DR: Define a strict JSON Schema for every codegen artifact (route, migration, React component props). Use Bedrock/response_format/tool-args validation, then Ajv (or zod) before write. Map JSON → ts-morph/AST or Handlebars — not “paste this into the repo.” Fail closed on additionalProperties. See Cursor Rules for TypeScript Monorepos and LLM Coding Agents on AWS.

Schema first, prose never on the write path

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://acme.dev/schemas/http-route.json",
  "type": "object",
  "additionalProperties": false,
  "required": ["method", "path", "handlerName", "auth", "responseType"],
  "properties": {
    "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
    "path": { "type": "string", "pattern": "^/v[0-9]+/[a-z0-9-{}/]+$" },
    "handlerName": { "type": "string", "pattern": "^[A-Z][A-Za-z0-9]+Handler$" },
    "auth": { "enum": ["none", "session", "m2m"] },
    "responseType": { "type": "string", "pattern": "^[A-Z][A-Za-z0-9]+$" },
    "queryParams": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name", "type"],
        "properties": {
          "name": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$" },
          "type": { "enum": ["string", "int", "bool"] }
        }
      }
    }
  }
}
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true, strict: true });
const validate = ajv.compile(routeSchema);

export function parseRoute(raw: unknown) {
  if (!validate(raw)) {
    // ❌ Never write files when invalid
    throw new Error("schema_violation:" + ajv.errorsText(validate.errors));
  }
  return raw as RouteSpec;
}

Force the model into the schema

# Bedrock Converse with tool schema as the only output channel
TOOL = {
  "toolSpec": {
    "name": "emit_route_spec",
    "description": "Emit a single HTTP route spec",
    "inputSchema": {"json": route_schema_dict},
  }
}

resp = bedrock.converse(
  modelId=MODEL,
  toolConfig={"tools": [TOOL], "toolChoice": {"tool": {"name": "emit_route_spec"}}},
  messages=[{"role": "user", "content": [{"text": prompt}]}],
)
# Extract toolUse input → validate again client-side

toolChoice specific forces structured args.
❌ “Return JSON in a markdown fence” — parsers will eventually fail on a witty preface.

Deterministic compile step

// codegen/compileRoute.ts — JSON → source via ts-morph (illustrative)
import { Project } from "ts-morph";

export function compileRoute(spec: RouteSpec, outFile: string) {
  const project = new Project();
  const sf = project.createSourceFile(outFile, "", { overwrite: true });
  sf.addImportDeclaration({
    moduleSpecifier: "@/http",
    namedImports: ["Router", spec.responseType],
  });
  sf.addFunction({
    name: spec.handlerName,
    isExported: true,
    parameters: [{ name: "req", type: "Request" }, { name: "res", type: `Response<${spec.responseType}>` }],
    statements: writer => {
      writer.writeLine(`// auth=${spec.auth}`);
      writer.writeLine(`throw new Error("TODO implement ${spec.method} ${spec.path}");`);
    },
  });
  sf.saveSync();
}

Keep transforms pure: same JSON → same bytes (stable formatting with prettier in CI).

Reject before git add

# scripts/codegen-guard.sh
node dist/codegen/run.js --spec "$1" --dry-validate
node dist/codegen/run.js --spec "$1" --write
pnpm exec tsc -p packages/api --noEmit
pnpm exec eslint "$(jq -r .outFile "$1")"
# ❌ Don't: agent writes .ts directly from prose

Wire review bots to reject PRs where new handlers lack matching schema artifacts — AI Code Review Bots.

Version the schema like an API

Bump $id / version when fields change. Keep a fixtures corpus: golden JSON → golden .ts snapshots in CI so prompt drift cannot silently change output shape.

Closing checklist

✅ Dos
– ✅ JSON Schema with additionalProperties: false
– ✅ Force tool/structured output; re-validate client-side
– ✅ Compile via AST/templates, not string paste
– ✅ Typecheck + lint generated files in the same job
– ✅ Snapshot golden outputs for schema versions

❌ Don’ts
– ❌ Don’t write to disk on schema failure
– ❌ Don’t accept markdown-fenced “JSON”
– ❌ Don’t let the model invent field names outside the schema
– ❌ Don’t skip prettier/tsc on generated paths
– ❌ Don’t hand-edit generated files without updating the spec

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply