DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Structured Outputs has no optional fields, so our model schema and our stored schema are different types

Nakodo reads a brand's website and writes a campaign brief from it: what they sell, who buys it, which countries and languages, the kind of creator to look for. The brief is then edited by the brand and used by the rest of the pipeline, which means it has to be a type the code can rely on.

The model fills it in through OpenAI Structured Outputs, and one line in the schema file explains most of the design:

// Structured Outputs requires every field to be present, so nothing is optional.
// Value checks (ISO codes, ranges) happen in sanitizeBrief instead of the schema.
Enter fullscreen mode Exit fullscreen mode

That constraint has a bigger blast radius than it looks.

One: the model's schema cannot be your stored schema

Strict mode requires every property to be in required. A field that is genuinely absent sometimes has nowhere to go, so an output schema is a worse description of your data than you would otherwise write. We stopped trying to use one type for both.

The stored brief has optional fields, as a stored thing should:

export const briefSchema = z.object({
  ...briefCore,
  assumptions: storedGuesses,
  assumptionsChecked: z.boolean().optional(),
  idealCreator: idealCreatorSchema,
  idealBusiness: idealBusinessSchema.optional(),
  widen: z.boolean().optional(),
});
Enter fullscreen mode Exit fullscreen mode

The model's schemas share the core and then differ, one per kind of campaign, with nothing optional anywhere:

// What research returns, one per campaign kind (Structured Outputs can't have
// optional fields).
export const researchOutputSchema = z.object({
  ...briefCore,
  assumptions: guesses(CREATOR_PARTS),
  idealCreator: idealCreatorSchema,
  brandName: z.string(),
});
export const businessResearchOutputSchema = z.object({
  ...briefCore,
  assumptions: guesses(BUSINESS_PARTS),
  idealBusiness: idealBusinessSchema,
  brandName: z.string(),
});
Enter fullscreen mode Exit fullscreen mode

Two schemas rather than one with a union, because a union in strict mode is a way of telling the model that two shapes are acceptable, and then being surprised by which one it picks. Asking a narrower question gets a narrower answer.

The cost of two schemas is that one of the two fields is dead weight on the other kind, so the dead weight is a named constant rather than a shrug:

// A business brief's unused creator range.
export const NO_IDEAL_CREATOR: Brief["idealCreator"] = { minSubscribers: 0, maxSubscribers: 0, contentStyle: "" };
Enter fullscreen mode Exit fullscreen mode
// Business campaigns have idealBusiness; their idealCreator is unused and kept
// only so every brief has the same shape.
Enter fullscreen mode Exit fullscreen mode

Every brief in the database has the same keys. The alternative, two brief types all the way through the app, would have put a conditional in every read site in exchange for tidiness in one.

Two: narrow the enum rather than validating the answer

Each guess the model makes about the brand points at the part of the brief the brand should check. The set of valid parts depends on the campaign kind: only business campaigns have places, only creator campaigns have a follower range. That could be a runtime check. It is cheaper as a schema:

const guesses = (parts: readonly [BriefPart, ...BriefPart[]]) =>
  z
    .array(
      z.object({
        note: z.string().describe("The guess, in a few words"),
        part: z.enum(parts).describe("The part of the brief the guess is in"),
      }),
    )
    .describe("Guesses the user should double-check");

export const partsFor = (kind: CampaignKind): readonly [BriefPart, ...BriefPart[]] =>
  kind === "businesses" ? BUSINESS_PARTS : CREATOR_PARTS;
Enter fullscreen mode Exit fullscreen mode

z.enum becomes an enum in the JSON Schema, so a business campaign is never offered idealCreator as an answer. The tuple type readonly [BriefPart, ...BriefPart[]] is doing real work there too: z.enum of an empty array is a type error rather than a schema that accepts nothing at midnight on the day someone refactors the lists.

.describe() is worth more than it looks in this setup. It is the only documentation that lands in both places at once: in the TypeScript type for us and in the JSON Schema the model sees.

const targetMarketSchema = z.object({
  countryCode: z.string().describe("ISO 3166-1 alpha-2, uppercase, e.g. US"),
  languageCode: z.string().describe("ISO 639-1, lowercase, e.g. en"),
});
Enter fullscreen mode Exit fullscreen mode

Three: a valid shape is not valid data, and that is the whole job of the sanitizer

Which brings us to the sentence above. countryCode: z.string() with a description saying "ISO 3166-1 alpha-2" is satisfied by "UK", by "united kingdom", by "", and by "GB ". Strict mode will faithfully give you a string. What it cannot do is make the string mean anything, and there is no schema feature that fixes this: you can add a regex and then your option is to throw away an otherwise good brief because one of its four markets was typed oddly.

So the value rules live in one pure function, which every path goes through, including brand edits from the browser:

// Normalizes model output (or user edits) into a brief the pipeline can rely on.
const COUNTRY_ALIASES: Record<string, string> = { UK: "GB", EL: "GR" };

export function sanitizeBrief(raw: Brief): Brief {
  const markets: TargetMarket[] = [];
  for (const m of raw.targetMarkets) {
    const typed = m.countryCode.trim().toUpperCase();
    const countryCode = COUNTRY_ALIASES[typed] ?? typed;
    const languageCode = m.languageCode.trim().toLowerCase();
    if (!/^[A-Z]{2}$/.test(countryCode) || !/^[a-z]{2}$/.test(languageCode)) continue;
    if (markets.some((x) => x.countryCode === countryCode && x.languageCode === languageCode)) continue;
    markets.push({ countryCode, languageCode });
  }
  if (markets.length === 0) markets.push({ countryCode: "US", languageCode: "en" });
Enter fullscreen mode Exit fullscreen mode

Three behaviours in nine lines, and they are each a decision:

  • Alias, do not reject. UK is not an ISO 3166-1 alpha-2 code; GB is. EL is Greece's code in a lot of EU paperwork; ISO says GR. Both are what a careful human would write, so both are fixed rather than dropped. Two entries is not a country table, it is a bug report from production.
  • Drop the bad row, keep the brief. A market that still does not match the pattern is skipped and the other markets survive. The brand sees a brief with three markets instead of four, and can add the fourth by hand.
  • Never return nothing. Zero valid markets falls back to US/en rather than throwing, because every consumer downstream assumes at least one market, and an empty array would turn a slightly odd brief into a crash at search time.

The rest of the function is the same instinct applied field by field: a group's language falls back to the first market's language rather than being discarded, every list is deduplicated case-insensitively against trimmed and whitespace-collapsed text, and the counts are capped (MAX_GROUPS 8, MAX_KEYWORDS_PER_GROUP 8, MAX_GUESSES 4, negative keywords 30). The numeric range gets the two fixes that numbers always need:

let min = Math.max(0, Math.round(raw.idealCreator.minSubscribers));
let max = Math.max(0, Math.round(raw.idealCreator.maxSubscribers));
if (max < min) [min, max] = [max, min];
if (max === 0) max = 1_000_000;
Enter fullscreen mode Exit fullscreen mode

A model that returns min: 50000, max: 5000 has not produced garbage, it has produced the right idea in the wrong order, and swapping is a better answer than refusing. max === 0 means "no upper bound stated", and a range of 0 to 0 would match no creator alive.

MAX_GUESSES has its own comment, which is the kind of reason that belongs next to a number:

// Each guess rings a part of the brief, so a long list rings the whole page.
export const MAX_GUESSES = 4;
Enter fullscreen mode Exit fullscreen mode

Four: the shape in the database is a history, not a type

Two weeks of a shipped feature was enough to give us two shapes of the same field. Guesses used to be plain sentences and are now objects that name the part they belong to, so the stored schema reads both and normalizes on the way in:

// As stored: older briefs kept each guess as a plain sentence.
const storedGuesses = z.array(
  z
    .union([z.string(), z.object({ note: z.string(), part: z.enum(BRIEF_PARTS).nullable() })])
    .transform((a): Assumption => (typeof a === "string" ? { note: a, part: null } : a)),
);
Enter fullscreen mode Exit fullscreen mode

There is a trap in that pattern, and the fix is one of my favourite comments in the repo:

// A brief's guesses, with the older plain-sentence ones given a null part.
// Stored briefs are read straight from jsonb, so they aren't parsed first.
export function readAssumptions(brief: Pick<Brief, "assumptions">): Assumption[] {
  return (brief.assumptions as (Assumption | string)[]).map((a) => (typeof a === "string" ? { note: a, part: null } : a));
}
Enter fullscreen mode Exit fullscreen mode

A .transform() on the stored schema only runs when something actually parses with that schema. The brief comes out of a jsonb column through the query builder as a typed value, with no parse step anywhere, so the transform never runs on a read and the legacy strings arrive as strings. The type says Assumption[] and reality says (Assumption | string)[]. If your ORM gives you typed JSON columns, every .transform() in that type is a lie unless you can point at the parse.

Five: the SDK will not tell you the call failed

The last part of trusting a schema is handling the responses that have no output at all. Every AI call in the app goes through one function, and these two checks are the ones people skip:

if (response.status === "incomplete") {
  throw new AiError(`${task}: output incomplete (${response.incomplete_details?.reason ?? "unknown"})`);
}
if (response.output_parsed == null) {
  throw new AiError(`${task}: no structured output (model refused or returned nothing)`);
}
Enter fullscreen mode Exit fullscreen mode
// A response that came back but can't be used: cut off, refused or empty.
// Asking again with the same input gets the same, so it's `permanent`, and a
// job that hits it gives up rather than paying for the same failure again.
export class AiError extends Error {
  readonly permanent = true;
Enter fullscreen mode Exit fullscreen mode

A 200 with status: "incomplete" is a response that ran into max_output_tokens partway through a JSON object. A 200 with output_parsed == null is a refusal. Neither throws on its own, both look like success to code that goes straight for the parsed field, and retrying either one with the same input buys the same answer at the same price. Hence permanent, which our job runner reads as "do not use the other two attempts".

And the usage is recorded before those throws, with a one line comment that is really an accounting policy:

// Paid for whether or not it can be used.
await recordUsage(task, usage).catch((e) => console.error("Recording AI usage failed", e));
Enter fullscreen mode Exit fullscreen mode

The pattern, in four lines

  • The schema the model answers in is an interface, so keep it narrow, required and per case.
  • The schema you store is a type with a history, so keep it honest about what is optional and what is legacy.
  • Everything between them is a pure function that normalizes rather than rejects, because a brief with one odd field is worth more to the user than an error.
  • Then handle the two no-output cases, because strict mode makes the shape safe and nothing else.

The brief this all produces is step one of how Nakodo works, and the thing it is not is the brief you send to a creator: our guide to writing a sponsorship brief covers that one, which is a document for a human and has no schema at all.

Top comments (0)