Design notes

A structured-output guarantee library for TypeScript/JS. Define the output shape with Zod; the LLM always returns schema-conformant data.

1. Background and problem

LLM output is free-form text, and developers face one core pain: after every call they write a pile of parsing, validation, and fallback code.

// Typical post-processing today
const raw = await llm.chat(...)
const json = JSON.parse(raw)          // may throw
if (!json.name) throw ...              // manual per-field checks
if (typeof json.age !== 'number') ... // defensive code everywhere

The Python ecosystem's answer: Instructor (10k+ stars), built on Pydantic — one line guarantees schema-conformant output.

The TypeScript ecosystem: had no equivalent. zodstructor fills that gap.

2. Target users

TypeScript developers using LLMs in Node.js, Bun, or Edge runtimes.

3. Core design principles

PrincipleMeaning
Zero required deps (except zod)LLM SDKs install on demand — no forced 300MB dependency tree
Provider-agnosticOne API adapts OpenAI / Anthropic / Google / any OpenAI-compatible endpoint
Schema-firstDefine the shape with Zod; the library handles conversion, injection, validation, retry
Automatic retryOn validation failure, feed the errors back to the LLM so it repairs itself
Streaming compatibleCollect the full JSON, validate once; optional partialSchema for best-effort prefix checks

4. API design

4.1 Basic usage

import { z } from 'zod'
import { createZodstructor, OpenAIProvider } from 'zodstructor'

// 1. Define the schema
const UserSchema = z.object({
  name: z.string(),
  age: z.number().int().min(0),
  email: z.string().email(),
  tags: z.array(z.string()),
})

// 2. Create the client
const client = createZodstructor(new OpenAIProvider({ apiKey: '...' }))

// 3. Call — output is guaranteed to match UserSchema
const user = await client.completion({
  schema: UserSchema,
  messages: [
    { role: 'user', content: 'Extract: Ada, 36, ada@example.com, tags: admin, security' }
  ],
  model: 'gpt-4o-mini',
})
// user's type is inferred as { name: string; age: number; email: string; tags: string[] }

4.2 Streaming usage

import { streamStructured } from 'zodstructor'

const stream = streamStructured({
  provider: new OpenAIProvider({ apiKey: '...' }),
  schema: UserSchema,
  messages: [{ role: 'user', content: '...' }],
})

for await (const chunk of stream) {
  process.stdout.write(chunk)  // raw JSON text, chunk by chunk
}
const result = await stream.return()
console.log(result.value)  // validated complete object

4.3 Switching providers

// Only the provider changes; everything else stays
const client = createZodstructor(new AnthropicProvider({ apiKey: '...' }))
// or
const client = createZodstructor(new GoogleProvider({ apiKey: '...' }))
// or any OpenAI-compatible endpoint
const client = createZodstructor(new OpenAIProvider({
  apiKey: '...', baseURL: 'https://api.deepseek.com/v1'
}))

4.4 Automatic retry & error feedback

const result = await client.completion({
  schema: z.object({ score: z.number().min(0).max(100) }),
  messages: [{ role: 'user', content: '...' }],
  maxRetries: 3,                    // up to 3 retries (default)
  onRetry: (attempt, error) => {    // called on every retry
    console.log(`retry ${attempt}: ${error.message}`)
  },
})

5. Architecture

src/
├── index.ts                    # unified exports
├── core/
│   ├── types.ts                # all type definitions
│   ├── completion.ts           # Zodstructor main class + createZodstructor
│   └── retry.ts                # retry engine (core logic)
├── providers/
│   ├── base.ts                 # abstract BaseProvider
│   ├── openai.ts               # OpenAI / OpenAI-compatible
│   ├── anthropic.ts            # Anthropic (Claude)
│   ├── google.ts               # Google Gemini
│   └── index.ts
├── streaming/
│   └── index.ts                # stream collection + deferred validation
└── utils/
    └── schema.ts               # Zod → JSON Schema + JSON extraction

6. Core flow

6.1 Retry engine flow

input: schema + messages + provider
  │
  ├─ 1. Zod → JSON Schema conversion
  ├─ 2. inject schema into system prompt
  │
  ├─ 3. call LLM → raw text
  ├─ 4. extractJson() → pure JSON
  ├─ 5. JSON.parse()
  ├─ 6. schema.safeParse()
  │
  ├─ pass → return typed data
  │
  └─ fail
       ├─ attempts left
       │   ├─ append assistant message (the invalid output)
       │   ├─ append user message (error description + required schema)
       │   └─ back to step 3
       │
       └─ attempts exhausted → throw ValidationError

6.2 Schema conversion coverage

Zod typeJSON Schema
z.string(){ type: "string" }
z.number() / .int(){ type: "number" } / { type: "integer" }
z.boolean(){ type: "boolean" }
z.array(T){ type: "array", items: T }
z.object({...}){ type: "object", properties, required }
z.enum([...]) / z.literal(V){ enum: [...] } / { enum: [V] }
z.union([...]){ anyOf: [...] }
optional/default/catchgeneric: not in required; strict: in required as anyOf [T, null]
z.nullable(T)generic: nullable: true; strict: anyOf [T, null]
z.tuple([...])prefixItems + minItems/maxItems
z.record(V){ type: "object", additionalProperties: V }
z.intersection(A, B){ allOf: [A, B] }
z.discriminatedUnion(...){ oneOf: [...] }
z.date(){ type: "string", format: "date-time" }
.describe('...')description: "..."
.min(N) / .max(N)minimum / maxLength etc.

7. Provider interface

interface LLMProvider {
  name: string
  complete(params: CompletionParams): Promise<string>
  stream?(params: CompletionParams): AsyncIterable<string>
}

interface CompletionParams {
  messages: ChatMessage[]
  model?: string
  schema: JsonSchema         // JSON Schema for injection
  schemaName?: string
  maxTokens?: number
  temperature?: number
  mode?: OutputMode          // auto | json_schema | json_object | tool_call | prompt
  structured?: boolean       // legacy API; true ≡ json_schema
}

Provider strategies

ProviderStructured-output strategy
OpenAI chat.completionsresponse_format: json_schema, or tools + tool_choice for tool_call
OpenAI Responsestext.format: json_schema/json_object, or read output[*].function_call.arguments for tool_call
Anthropictools + tool_choice reading tool_use.input; otherwise system-prompt injection + raw-JSON instruction
GoogleresponseMimeType: "application/json" + responseSchema
OpenAI-compatibleresponse_format: { type: "json_object" } + prompt injection

8. Dependency design

dependencies:      zod (only)
peerDependencies:  openai (optional) | @anthropic-ai/sdk (optional) | @google/generative-ai (optional)
devDependencies:   tsup, typescript, vitest

Users install only the provider SDKs they actually use.

9. Comparison with Instructor (Python)

FeatureInstructor (Python)zodstructor
Schema definitionPydanticZod
LanguagePythonTypeScript
Retry mechanismYesYes
StreamingYesYes
Multi-providerYesYes
Native JSON SchemaOpenAI onlyOpenAI + Google
Peer deps strategyOn demandOn demand
Bundle size~50KBtarget <30KB

10. Release plan

  • v0.1.0 — core: Zod→JSON Schema, OpenAI provider, auto retry, unit tests
  • v0.2.0 — Anthropic + Google providers, streaming
  • v0.3.0 — OpenAI-compatible auto-discovery, more schema types
  • v1.0.0 — stable API, complete docs, production ready