Star5

OpenAI Structured Outputs in Angular: Type-Safe AI with Signals

Stop parsing brittle LLM JSON by hand. Use OpenAI Structured Outputs with Zod schemas to get guaranteed, typed AI responses wired into Angular signals.

RJ
RAMA JONNADA
Senior Frontend Developer with 4+ years of experience in Angular and modern web technologies.

TL;DR: OpenAI Structured Outputs guarantee that a model's response matches a JSON schema you define — no more JSON.parse() guesswork, no more as SomeType casts, no more runtime crashes when the model improvises. Wire it into an Angular service backed by signals and you get a fully reactive, type-safe AI layer with almost no boilerplate.


Every team building AI features in Angular eventually writes the same brittle service: call the OpenAI API, get back a string, try to JSON.parse() it, cast it to a TypeScript interface, and ship a try/catch around the whole thing hoping the model formatted the JSON correctly today. It works until it doesn't — usually in production, usually for a user who entered something the model found ambiguous.

OpenAI Structured Outputs, available through the response_format parameter, solve this at the API level. The model is constrained to produce output that matches a schema you provide. The TypeScript SDK goes further: when you use zodResponseFormat, the parsed result is fully typed at compile time, no cast required. Combined with Angular signals for reactive state, you can build a clean, testable AI service in under 50 lines.


The Problem: Unstructured LLM Responses Break Silently

Consider a feature where a user pastes a job description and your Angular app extracts structured data — role title, required skills, salary range — to pre-fill a form. The naive implementation looks like this:

// Fragile — the model decides the format
async extractJobData(description: string): Promise<JobData> {
  const response = await this.openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: 'Extract job data as JSON.' },
      { role: 'user', content: description },
    ],
  });

  const raw = response.choices[0].message.content ?? '';
  // The model might wrap the JSON in ```json ... ```, or add a preamble,
  // or use different key names, or omit optional fields, or hallucinate extras.
  return JSON.parse(raw) as JobData; // fingers crossed
}

This fails in all the ways you expect: the model wraps the JSON in a code fence, uses "salary" instead of "salaryRange", or returns null for a field your TypeScript type marks as required. Every failure mode is silent until runtime.


The Concept: OpenAI Structured Outputs

Structured Outputs lock the model's response to a JSON schema you supply in response_format. The model cannot produce output that violates the schema — it is enforced at the inference level, not by post-processing. The TypeScript SDK's zodResponseFormat helper takes a Zod schema, builds the JSON schema automatically, and gives you back a fully typed parsed object.

Install the dependencies:

npm install openai zod

Define your schema with Zod:

// job-extraction.schema.ts
import { z } from 'zod';

export const JobDataSchema = z.object({
  title: z.string(),
  company: z.string().optional(),
  skills: z.array(z.string()),
  salaryRange: z.object({
    min: z.number().nullable(),
    max: z.number().nullable(),
    currency: z.string().default('USD'),
  }),
  remote: z.boolean(),
});

export type JobData = z.infer<typeof JobDataSchema>;

Now the extraction call with Structured Outputs:

import OpenAI from 'openai';
import { zodResponseFormat } from 'openai/helpers/zod';
import { JobDataSchema, JobData } from './job-extraction.schema';

const openai = new OpenAI({ apiKey: process.env['OPENAI_API_KEY'] });

async function extractJobData(description: string): Promise<JobData> {
  const response = await openai.beta.chat.completions.parse({
    model: 'gpt-4o-2024-08-06', // structured outputs require this model or newer
    messages: [
      { role: 'system', content: 'Extract structured job posting data from the text.' },
      { role: 'user', content: description },
    ],
    response_format: zodResponseFormat(JobDataSchema, 'job_data'),
  });

  // .parsed is fully typed as JobData — no cast, no JSON.parse — but it can
  // still be null (content filter, context overflow), so check before
  // returning rather than force-asserting with `!`.
  const parsed = response.choices[0].message.parsed;
  if (!parsed) {
    throw new Error('Model did not return a parseable structured response.');
  }
  return parsed;
}

response.choices[0].message.parsed is typed as JobData by the SDK. If the model somehow cannot produce a valid response (content filter, context overflow), parsed is null and you handle that case explicitly — no surprise exceptions.


Wiring It Into an Angular Service with Signals

Calling the OpenAI API directly from the browser exposes your API key. In production you route requests through a backend — a Node server, a Cloudflare Worker, or an Angular SSR API route. The Angular service calls your backend endpoint, which holds the key and calls OpenAI.

Here is the Angular side, using httpResource() for reactive data fetching backed by a signal:

// job-extraction.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { JobData, JobDataSchema } from './job-extraction.schema';

@Injectable({ providedIn: 'root' })
export class JobExtractionService {
  private http = inject(HttpClient);

  readonly inputText = signal('');
  readonly isLoading = signal(false);
  readonly error = signal<string | null>(null);
  readonly result = signal<JobData | null>(null);

  readonly hasResult = computed(() => this.result() !== null);

  async extract(): Promise<void> {
    const text = this.inputText();
    if (!text.trim()) return;

    this.isLoading.set(true);
    this.error.set(null);
    this.result.set(null);

    try {
      const data = await firstValueFrom(
        this.http.post<JobData>('/api/extract-job', { description: text })
      );
      const parsed = JobDataSchema.safeParse(data);
      if (!parsed.success) {
        throw new Error('Server returned a response that does not match JobData.');
      }
      this.result.set(parsed.data);
    } catch (err) {
      this.error.set('Extraction failed. Please try again.');
    } finally {
      this.isLoading.set(false);
    }
  }
}

The component consumes the signals directly with no subscription management:

// job-form.component.ts
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { JobExtractionService } from './job-extraction.service';

@Component({
  selector: 'app-job-form',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <textarea
      [value]="svc.inputText()"
      (input)="svc.inputText.set($any($event.target).value)"
      placeholder="Paste a job description..."
    ></textarea>

    <button (click)="svc.extract()" [disabled]="svc.isLoading()">
      {{ svc.isLoading() ? 'Extracting...' : 'Extract Data' }}
    </button>

    @if (svc.error()) {
      <p class="error">{{ svc.error() }}</p>
    }

    @if (svc.hasResult()) {
      <ul>
        <li>Title: {{ svc.result()!.title }}</li>
        <li>Skills: {{ svc.result()!.skills.join(', ') }}</li>
        <li>Remote: {{ svc.result()!.remote ? 'Yes' : 'No' }}</li>
      </ul>
    }
  `,
})
export class JobFormComponent {
  svc = inject(JobExtractionService);
}

No async pipe, no subscription cleanup, no ngOnDestroy. The template reacts to signal changes automatically because ChangeDetectionStrategy.OnPush + signals is the modern Angular default.


The Backend: Node Route with Structured Outputs

The backend route is where the real OpenAI call happens. Here is an Express handler you can drop into an Angular SSR server or a standalone Node API:

// server/routes/extract-job.ts
import { Router } from 'express';
import OpenAI, { zodResponseFormat } from 'openai';
import { JobDataSchema } from '../schemas/job-extraction.schema';

const router = Router();
const openai = new OpenAI();

router.post('/api/extract-job', async (req, res) => {
  const { description } = req.body as { description: string };

  if (!description?.trim()) {
    return res.status(400).json({ error: 'description is required' });
  }

  const response = await openai.beta.chat.completions.parse({
    model: 'gpt-4o-2024-08-06',
    messages: [
      { role: 'system', content: 'Extract structured job posting data from the text.' },
      { role: 'user', content: description },
    ],
    response_format: zodResponseFormat(JobDataSchema, 'job_data'),
  });

  const parsed = response.choices[0].message.parsed;

  if (!parsed) {
    return res.status(422).json({ error: 'Model could not produce a valid response.' });
  }

  res.json(parsed);
});

export default router;

The Zod schema lives in a shared location imported by both the Angular service (for the TypeScript type) and the backend route (for the zodResponseFormat call). One source of truth for the shape of AI responses across the entire stack.


The New Tool: openai npm SDK with Structured Outputs

The official openai npm package (v4.50+) is what makes this workflow ergonomic. The key method is openai.beta.chat.completions.parse(), which:

  • Accepts zodResponseFormat(schema, name) in response_format
  • Returns response.choices[0].message.parsed typed as your Zod inference type
  • Sets parsed to null when the model triggers a content filter or runs out of context, so you handle the failure path explicitly rather than catching a parse exception
npm install openai@latest zod

For streaming structured outputs — and for a full streaming AI integration with Angular, see Streaming AI Responses with the Vercel AI SDK — useful when extraction takes several seconds and you want progressive UI updates — the SDK provides openai.beta.chat.completions.stream() with the same response_format parameter. The stream emits typed partial objects as tokens arrive, so you can show a skeleton that fills in field by field while the model reasons through the document.


Wrapping up

Structured Outputs shift the contract for AI responses from "hope the model formats it correctly" to "the API guarantees it matches your schema." The OpenAI Structured Outputs documentation covers supported models and schema constraints. Paired with zodResponseFormat for compile-time typing and Angular signals for reactive UI state, you get an AI integration layer that is reliable, testable, and readable. Add this pattern to your next AI feature instead of reaching for another JSON parse workaround.


Frequently asked questions

Which OpenAI models support Structured Outputs?

Structured Outputs are supported on gpt-4o-2024-08-06 and later, and on the o1 and o3 series models. They are not available on gpt-3.5-turbo or older gpt-4 snapshots. Check the OpenAI documentation for the current model list, as new compatible versions are added regularly.

What happens if the model cannot fulfill the schema — for example, if a required field has no value in the source text?

The model will still produce a valid JSON response that conforms to the schema. For required fields with no extractable value it will typically return an empty string or zero depending on the field type. If you want to allow absence explicitly, mark those fields as .nullable() or .optional() in your Zod schema. If the model hits a content filter or the prompt exceeds the context limit, parsed will be null and you should surface an error to the user.

Can I use this pattern with other AI providers, such as Anthropic or Google Gemini?

Yes — both Anthropic (via tool_choice: 'required' with a single tool schema) and Google Gemini (via response_mime_type: 'application/json' with a schema) offer equivalent forced-schema output modes. The Angular service and signals pattern is identical regardless of provider — only the backend route changes to use the provider's SDK instead of openai.

Is it safe to call the OpenAI API directly from the Angular app in development?

Technically it works, but it exposes your API key in the browser's network tab and source. In development you can use Angular's proxy configuration (proxy.conf.json) to route /api/* requests to a local Node server that holds the key. In production always use a server-side handler — Angular SSR routes, a Cloudflare Worker, or a dedicated backend. Never ship an API key in a browser bundle.


Related reading:

Topics
angularopenaistructured outputssignalsai