DEV Community

Cover image for Apple's Foundation Models, Part 2: Typed Output with @Generable, and What the Type System Can't Promise
Iniyarajan
Iniyarajan

Posted on

Apple's Foundation Models, Part 2: Typed Output with @Generable, and What the Type System Can't Promise

The on-device model can return a Swift struct instead of a string. I ran a real school notice through it twenty times, as text and as a photo, to find out exactly what the guarantee covers and where it stops.

TL;DR

  • Mark a struct or enum with @Generable, pass it to respond(generating:), and the framework returns an instance of your type. The model cannot add a field you did not declare or pick a value outside a guide you set. This is enforced at token level, not by parsing afterwards.
  • Guides do real work: a regex for dates, an enum for categories, a range for counts, a description for meaning. The whole schema is sent to the model as tokens, and it is not cheap. My eight-field schema cost 575 tokens, more than the 249-token notice it was extracting from.
  • Streaming gives you a partially filled struct as it generates. The first item appeared at 2.1 seconds of a 9-second response on an M1 Pro. Fields arrive in declaration order.
  • Since iOS 27, a photo goes in the same prompt. A 1800 by 1800 image of the notice cost 805 input tokens and took 12.9 seconds, and got one date wrong.
  • The type system guarantees shape, not truth. In one run out of ten the model invented two plausible items that were not on the notice. Optional fields were skipped in one run and filled in the next. You still need a validator, and this article shows the one I use.
  • iOS 27 changed the error type. Code that catches the iOS 26 GenerationError will not see a context overflow any more.

What @Generable guarantees, and what it doesn't: enforced at the token mask versus still yours to check, with the measured numbers

This is article 2 of 6. Article 1 covered what runs where and who can use it. Every snippet below was type-checked against the iOS 27.0 SDK and run on macOS 27.0 with the same on-device model, so the numbers are from real executions, not from the documentation.


The problem with asking for JSON

Every LLM integration I have shipped had the same weak spot. You ask the model for JSON, it returns something that is nearly JSON, and you write a parser that copes with a missing bracket, a field called first_name instead of firstName, a number as a string, and an apology paragraph before the opening brace. The parser grows. The tests multiply. The failures are silent until they are not.

The Foundation Models framework removes that layer entirely with what Apple calls guided generation. You declare the shape as a Swift type. The framework turns the type into a schema, sends the schema with your prompt, and then, at every step of generation, masks out every token that would break the schema. The model is not asked to follow the structure. It is prevented from leaving it. Apple's engineers put it plainly in the WWDC25 deep dive: for every token the model has a distribution over its vocabulary, and constrained decoding zeroes out the entries that are not valid according to the schema.

That is a stronger guarantee than JSON mode on a cloud API, which still hands you a string to parse. Here it hands you a value of your type. What follows is what that looks like on a task with real stakes for real users.


Declare the type, get the type

My test document is a school circular, the kind that arrives in a parents' WhatsApp group as a photo every few weeks: a holiday, a parent-teacher meeting, a fee deadline with a late charge, an annual day with rehearsals, and a consent form due date. Five facts, six dated items, one of them an amount of money. The goal is to turn it into calendar entries and reminders without a server.

Here is the type that describes what I want back.

// snippet: context
import Foundation
import FoundationModels

@Generable(description: "One dated item a parent must know about, from a school notice")
struct NoticeItem {
    @Guide(description: "What the item is about, under 8 words")
    var title: String

    @Guide(description: "Calendar date in ISO format", .pattern(#/\d{4}-\d{2}-\d{2}/#))
    var date: String

    @Guide(description: "Start time if the notice gives one, as HH:MM 24-hour")
    var time: String?

    var kind: Kind
    var action: Action

    @Generable
    enum Kind { case holiday, meeting, payment, event, submission }

    @Generable
    enum Action {
        case none
        case pay(amountINR: Int)
        case attend
        case submit(item: String)
    }
}

@Generable(description: "A school circular broken into actionable items")
struct SchoolNotice {
    @Guide(description: "School name exactly as printed")
    var school: String

    @Guide(description: "Items in the order they appear", .count(1...8))
    var items: [NoticeItem]

    @Guide(description: "True if any item needs the parent to do something")
    var needsParentAction: Bool
}
Enter fullscreen mode Exit fullscreen mode

Three things to notice. Kind is a plain enum, so the model can only produce one of five values. Action is an enum with associated values, so "pay" carries an amount and "submit" carries what to submit, and the model has to fill those in when it picks that case. And date has a regex guide, written with the #/…/# delimiters. The bare /…/ form in Apple's examples needs the -enable-bare-slash-regex compiler flag, which Swift packages set by default and a plain swiftc invocation does not. The extended delimiters work everywhere.

Calling the model is one line more than calling it for text.

let instructions = "You turn school circulars into structured calendar items for parents. Use only facts in the notice. Dates are in 2026."

func extract(_ noticeText: String) async throws -> SchoolNotice {
    let session = LanguageModelSession(instructions: instructions)
    let response = try await session.respond(
        to: "Extract the items from this notice:\n\(noticeText)",
        generating: SchoolNotice.self
    )
    return response.content
}
Enter fullscreen mode Exit fullscreen mode

Here is what came back on the first run, printed from the returned struct:

school: ST. MARY'S HIGH SCHOOL, Coimbatore | needsParentAction: true | 6 items
 - 2026-10-02 [holiday]    Gandhi Jayanti - school closed        → none
 - 2026-10-03 [meeting]    Parent-Teacher Meeting                → attend
 - 2026-10-05 [payment]    Second term fees due                  → pay(amountINR: 12500)
 - 2026-10-18 [event]      Annual Day                            → none
 - 2026-10-12 [event]      Rehearsals start                      → none
 - 2026-09-30 [submission] Consent form for Science Exhibition   → submit(item: "Science Exhibition trip")
Enter fullscreen mode Exit fullscreen mode

Every date matches the regex. Every kind is one of the five cases. The fee amount is an integer inside the enum case that requires one. Nothing was parsed. On a warm session this took between 6.8 and 9.0 seconds across three runs for around 270 to 370 output tokens, which works out at roughly 40 tokens per second on an M1 Pro from 2021. Apple's Mac path and the iPhone path run the same model, so treat that as a lower bound for a phone.


What guides actually do, and what they cost

A guide is a constraint the decoder enforces. The SDK ships these:

Guide Applies to Effect
.range(a...b), .minimum, .maximum Int, Float, Double, Decimal Number must fall inside the bounds
.count(n), .count(a...b), .minimumCount, .maximumCount Arrays Array length is bounded
.anyOf([...]), .constant(...) String Value must be one of the listed strings
.pattern(regex) String Value must match the regex, enforced while generating
.element(guide) Arrays Applies a guide to each element
description: Anything Not a constraint. Tells the model what the field means

The description is the one that is not enforced and the one you will reach for most. It is also the one that costs you. The schema is sent to the model as tokens on every request by default, and the framework will tell you exactly how many:

func printBudget(for noticeText: String) async throws {
    let model = SystemLanguageModel.default
    let schemaTokens = try await model.tokenCount(for: SchoolNotice.generationSchema)
    let noticeTokens = try await model.tokenCount(for: noticeText)
    print(schemaTokens, noticeTokens, model.contextSize)
}
Enter fullscreen mode Exit fullscreen mode

On my machine that printed 575 for the schema, 249 for the notice, and 4096 for the context window. The type definition cost more than twice the document it was extracting from. Apple's own advice is to keep descriptions short because long ones "take up additional context size and can introduce latency", and the numbers back that up: every description you write is paid for on every call.

There is a lever for that. respond(generating:includeSchemaInPrompt:) accepts false, and the decoder still enforces the schema because that part happens at the token mask, not in the prompt. I tried it:

func extractCheap(_ noticeText: String) async throws -> SchoolNotice {
    let session = LanguageModelSession(instructions: instructions)
    let response = try await session.respond(
        to: "Extract the items from this notice:\n\(noticeText)",
        generating: SchoolNotice.self,
        includeSchemaInPrompt: false
    )
    print(response.usage.input.totalTokenCount)   // usage is new in iOS 27
    return response.content
}
Enter fullscreen mode Exit fullscreen mode

Input tokens went from 906 to 340, a 62 percent cut, and the six items came back correctly typed with sensible titles. What the model loses is the descriptions, so it is guessing at meaning from field names alone. For a self-explanatory schema on a simple extraction that was fine. For anything where a description is doing real work, put the schema into the session's instructions once, keep the flag off for every turn after that, and let the framework's prompt cache carry it. On the second turn of one such session the response reported 908 of 1,392 input tokens as cached.

Two other numbers from that run matter. The context window on this Mac is 4,096 tokens, not the 8,192 that Apple's WWDC26 session prints for the same property. I do not know whether that is the hardware, the OS build, or the model variant, which this machine reports as "AFM 3 Core". The point is that contextSize is a property for a reason. Read it at runtime, never hard-code it, and budget the schema against it.


Streaming: a struct that fills in front of you

For anything that takes nine seconds, the UI needs to move earlier than that. The framework streams partially generated values of your type. Every @Generable type gets a PartiallyGenerated companion in which every property is optional, and the stream hands you a new snapshot each time a token lands.

func streamExtract(_ noticeText: String) async throws {
    let session = LanguageModelSession(instructions: instructions)
    let stream = session.streamResponse(
        to: "Extract the items from this notice:\n\(noticeText)",
        generating: SchoolNotice.self
    )
    for try await snapshot in stream {
        let partial = snapshot.content
        let itemsSoFar = partial.items?.count ?? 0
        print(partial.school ?? "…", itemsSoFar, partial.needsParentAction.map(String.init) ?? "pending")
    }
}
Enter fullscreen mode Exit fullscreen mode

The measured timeline for one run:

+1576 ms  school="ST."                              items=0  needsParentAction=nil
+2067 ms  school="ST. MARY'S HIGH SCHOOL, Coimbatore" items=1  needsParentAction=nil
+2800 ms                                             items=2
+3619 ms                                             items=3
 ...
+7904 ms                                             items=7
 9067 ms  done, 125 snapshots
Enter fullscreen mode Exit fullscreen mode

Two things to design around. First, the first complete item was visible at 2.1 seconds, less than a quarter of the total, so a list that appends rows as they arrive feels four times faster than a spinner. Second, properties are generated in declaration order, which Apple states outright. needsParentAction is declared last, so it is nil until the very end. If you want the model to decide something before it enumerates, declare that property first. If you want the decision to be informed by the enumeration, declare it last. The declaration order of your struct is part of your prompt.


A photo in, a struct out

Since iOS 27 the same prompt accepts an image. Parents do not receive notices as text, they receive a photo of a printed sheet, so this is the version that matters for the use case.

import CoreGraphics

func extract(from photo: CGImage) async throws -> SchoolNotice {
    let session = LanguageModelSession(instructions: instructions)
    let response = try await session.respond(generating: SchoolNotice.self) {
        "Extract the items from this photographed notice."
        Attachment(photo)
    }
    return response.content
}
Enter fullscreen mode Exit fullscreen mode

I rendered the notice as an 1800 by 1800 pixel image and passed it in. The result:

image → SchoolNotice in 12902 ms · tokens in=805 out=323
 - 2026-10-02 [holiday]    School closed                 → none
 - 2026-10-03 [meeting]    Parent-Teacher Meeting        → attend
 - 2026-10-03 [payment]    Second term fees              → pay(amountINR: 12500)
 - 2026-10-18 [event]      Annual Day                    → attend
 - 2026-10-12 [event]      Annual Day rehearsals         → attend
 - 2026-09-30 [submission] Consent form                  → submit(item: "Science Exhibition")
Enter fullscreen mode Exit fullscreen mode

The image cost fewer input tokens than the text plus schema did, at 805 against 906, and took about four seconds longer. Five of six dates are right. The fee deadline came back as 3 October instead of 5 October, which for a payment reminder is the one field you cannot get wrong. The struct is perfectly formed and one fact in it is false. Hold that thought.


Schemas you only know at runtime

Sometimes the shape depends on data you fetch. A school's categories, a restaurant's menu, a form's field list. DynamicGenerationSchema builds the same kind of schema from values, and the decoder enforces it the same way.

func tag(_ noticeText: String, categories: [String]) async throws -> [(String, String)] {
    let item = DynamicGenerationSchema(name: "Item", properties: [
        .init(name: "summary", schema: DynamicGenerationSchema(type: String.self)),
        .init(name: "category", schema: DynamicGenerationSchema(name: "Category", anyOf: categories)),
    ])
    let root = DynamicGenerationSchema(name: "Tagged", properties: [
        .init(name: "items", schema: DynamicGenerationSchema(
            arrayOf: DynamicGenerationSchema(referenceTo: "Item"), minimumElements: 1, maximumElements: 8)),
    ])
    let schema = try GenerationSchema(root: root, dependencies: [item])

    let session = LanguageModelSession(instructions: instructions)
    let response = try await session.respond(to: "Tag each item in this notice:\n\(noticeText)", schema: schema)
    let items = try response.content.value([GeneratedContent].self, forProperty: "items")
    return try items.map { (try $0.value(String.self, forProperty: "category"), try $0.value(String.self, forProperty: "summary")) }
}
Enter fullscreen mode Exit fullscreen mode

With categories fetched as fees, exam, holiday, event, transport, the model tagged the five notice items in 5.7 seconds, and it could not have produced a sixth category if it wanted to. You lose the typed Swift value and get GeneratedContent you read by property name, which is the right trade when the schema is data.


What the type system can't promise

This is the section I wanted to write, because the guarantee is so clean that it invites over-trust. I ran the text extraction ten times across different variants of the struct. Nine runs returned exactly the six dated facts in the notice. One run returned eight:

 - 2026-09-30 [submission] Consent form for Science Exhibition  → submit(...)
 - 2026-10-30 [submission] Consent form for Science Exhibition  → submit(...)
 - 2026-10-30 [event]      Rehearsals end                        → none
Enter fullscreen mode Exit fullscreen mode

A second consent deadline a month later, and an end date for rehearsals that the notice never mentions. Both match the date regex. Both have a valid kind. Both would land in a parent's calendar. The schema did its job perfectly and the output is wrong.

I suspected the .count(1...8) guide was inviting the model to pad towards eight. I tested that with three runs each of a count range, no count guide, and .maximumCount(8). All nine runs returned five or six items with no invented dates. So the padding was not caused by the guide, it was ordinary sampling variance, and it will happen to your users at whatever rate it happens, which in my sample was one in ten.

The optional field told the same story from the other side. time is declared String?. In one run the model filled it for zero of eight items. In another, for six of six. When I made it a required String with a rule to return an empty string when the notice gives no time, it filled two of five, correctly: 09:00 for the meeting, 17:30 for the annual day, empty for the rest. Optional properties are a suggestion to the model. If a field matters, make it required and define what "absent" looks like.

So the pattern that ships is: let the type system own the shape, and write a small validator that owns the truth. Mine is ten lines.

struct NoticeValidator {
    let noticeDate: Date
    let horizon: TimeInterval = 120 * 86_400   // nothing more than four months out

    func validate(_ notice: SchoolNotice) -> [NoticeItem] {
        let fmt = DateFormatter()
        fmt.dateFormat = "yyyy-MM-dd"
        var seen = Set<String>()
        return notice.items.filter { item in
            guard let d = fmt.date(from: item.date), d >= noticeDate, d <= noticeDate.addingTimeInterval(horizon) else { return false }
            if case .pay(let amount) = item.action, amount <= 0 { return false }
            return seen.insert(item.date + item.kind.rawValueForDedup).inserted
        }
    }
}

extension NoticeItem.Kind {
    var rawValueForDedup: String { String(describing: self) }
}
Enter fullscreen mode Exit fullscreen mode

Dates inside the notice's window, positive amounts, no duplicate date-and-kind pairs. That validator would have dropped both invented items. It would not have caught the fee date that was two days early in the photo run, and nothing short of showing the parent the original alongside the extraction will. For a payment deadline, that is the correct UI anyway.

One more rule I now follow: run every prompt three times before deciding a pattern works or does not. Both of my "findings" about optional fields reversed between runs. A single run of an on-device model tells you what is possible, not what is typical.


The error type changed in iOS 27

The last surprise was in the failure path. I fed the model forty copies of the notice to overflow the context and caught the iOS 26 error type:

// snippet: skip
} catch let error as LanguageModelSession.GenerationError {
    // iOS 26: .exceededContextWindowSize, .guardrailViolation, .decodingFailure ...
}
Enter fullscreen mode Exit fullscreen mode

It was not caught. In iOS 27 the session throws the new LanguageModelError, which is shared across on-device, Private Cloud Compute and third-party models, and its cases are different:

func safeExtract(_ noticeText: String) async -> SchoolNotice? {
    do {
        return try await extract(noticeText)
    } catch let error as LanguageModelError {
        switch error {
        case .contextSizeExceeded: print("Trim the input; the window on this device is \(SystemLanguageModel.default.contextSize) tokens")
        case .guardrailViolation, .refusal: print("The model declined this content")
        case .rateLimited, .timeout: print("Retry later")
        case .unsupportedGenerationGuide: print("A guide on this type is not supported by this model")
        case .unsupportedLanguageOrLocale, .unsupportedCapability, .unsupportedTranscriptContent: print("Route to a fallback model")
        @unknown default: print("Unknown: \(error)")
        }
        return nil
    } catch {
        print("Other error: \(error)")
        return nil
    }
}
Enter fullscreen mode Exit fullscreen mode

The message it carried was exact: "Content contains 10277 tokens, which exceeds the maximum allowed context size of 4096." If your iOS 26 code catches GenerationError and nothing else, the overflow now falls through to your generic handler. Catch both while you support both versions.


What goes in the kit

Module 2 of the OnDevice AI Starter Kit is this article as code: the SchoolNotice schema as a template for any "document to typed items" job, the streaming view model that appends rows as snapshots arrive, the validator with a protocol so you can swap the rules, a token budget helper that reads contextSize and the schema cost at launch, and a test target that runs each prompt three times and asserts on the distribution, not on a single answer.

Article 3 is tool calling: letting the model call your Swift functions to look things up, with the same typed arguments you have just seen, and the safety rules that stop it calling the wrong one.


Ship this without an API bill

The OnDevice AI Starter Kit is the SwiftUI template behind this series: availability handling, a session wrapper, typed output schemas, tool calling, streaming, and a fallback route to Claude, with tests and an ACT-iOS skill file so your coding agent understands it. Early-bird $29 until 31 October, then $49. Pre-orders are open on Gumroad and the kit ships on 27 October.

Get the kit →


Sources


📘 Go Deeper: Building AI Agents: A Practical Developer's Guide

185 pages covering autonomous systems, RAG, multi-agent workflows, and production deployment, with complete code examples. The validation-after-generation pattern in this article is the same one the book uses for every tool result.

Get the ebook →


Enjoyed this article?

I write about AI tools, AI agents, and iOS development with AI, practical tips you can use right away.

  • Follow me on Medium for the full series
  • Follow me on Dev.to for daily articles
  • Connect on Twitter/X for quick tips

If this helped you, drop a like and share it with a fellow developer!

Top comments (0)