はじめに
こんにちは、システム開発部のT.Nです。
前回の記事、SwiftUIとUIKitで Foundation Models を使ってみた:オンデバイス要約の最小実装 では、Foundation Models の基本的な使い方を最小構成で紹介しました。
今回はその続編として、Foundation Models の @Generable を使い、生成結果をただの文字列ではなく Swift の型(struct)として受け取るところまで踏み込んでみます。
題材として「料理名を入力すると、材料リスト(必要なら簡単なレシピ)を出力する」ミニアプリを作ります。
文字列で返すだけでも動きますが、材料をUIで一覧表示したり、材料の分量を扱ったりすることを考えると、任意の型で扱えることがかなり効いてきます。この記事では、まず「料理名 → 材料リスト」を型で受け取り、UIに表示してみることをゴールにします。
今回やること(完成イメージ)
今回作るのは、料理名を入力すると材料リストを生成して表示するシンプルなSwiftUIアプリです。
まずは前回の延長として、Foundation Models に問い合わせて 文字列として材料を返すところから始めます。ここまでなら実装は簡単ですが、返ってくる内容が文章だとUI側で扱いづらく、材料を一覧表示しにくいという課題があります。
そこで次に、Foundation Models の @Generable を使って、生成結果を Swiftの型(struct)として受け取り、材料を List で表示できる形に作り替えます。これにより、UI側での整形や拡張がしやすくなります。
最後に発展編として、任意で レシピ手順も同じく型として追加し、「材料+手順」まで返せるようにしてみます。
⚠️ 注意:Foundation Models は iOS 26+(iPadOS/macOS/visionOS も同様)かつ Apple Intelligence 有効化が前提です。
詳細は前回の記事をご参照ください。
文字列で材料を返す
最初に、前回の記事と同じ流れで 「モデルに問い合わせて、結果を文字列として受け取る」 ところまでを作ります。
この段階の目的は、とにかく 最小構成で動かすことです。UIは「入力欄+送信ボタン+結果表示」だけに絞ります。
コード全体
import SwiftUIimport FoundationModelsstruct ContentView: View { @State private var text: String = "" @State private var result: String = "" @State private var isLoading: Bool = false let model = SystemLanguageModel.default private func send(text: String) async throws { let instructions = """ あなたは料理の材料を教えてくれるアシスタントです。 ユーザーが入力した料理に必要な材料を返してください。 """ let session = LanguageModelSession(model: model, instructions: instructions) let prompt = """ ユーザーの入力は\(text)です。 この料理を作るのに必要な材料を教えてください。 """ isLoading = true let response = try await session.respond(to: prompt) isLoading = false self.result = response.content } var body: some View { if model.isAvailable { VStack { Text(""" こんにちは! 今から作りたい料理を教えてください! 材料を教えます! """) .padding() .fixedSize(horizontal: false, vertical: true) TextField("作りたいものはなんですか?", text: $text) .textFieldStyle(.roundedBorder) .padding() Button(action: { Task { do { try await send(text: text) } catch { result = "エラーが発生しました: \(error.localizedDescription)" } } }) { Text("送る") } .buttonStyle(.borderedProminent) .tint(.black) if isLoading { ProgressView() } else { ScrollView { Text(result) .padding() } } } } else { Text("Not available") } }}
出来上がったアプリ

実装としては LanguageModelSession を作り、respond(to:) を呼び出して返ってきた response.content(文字列)を画面に表示するだけです。
ただし、この方法だと返ってくるのはあくまで文章なので、例えば「材料を1件ずつ List に表示したい」「量だけ取り出したい」といった用途では、あとからパースや整形が必要になります。
次のセクションでは、この課題を @Generable で解決していきます。
@Generable を導入して、材料をListで扱える形にする
ここからは、Foundation Models の @Generable を使って、生成結果を「文章」ではなくSwift の型として受け取るようにします。
材料のクラスを @Generable で定義する
まず、モデルに生成してほしい材料の形を @Generable で定義します。今回は「材料名」と「目安の量」だけに絞ってシンプルにしました。
@Generablepublic struct Ingredient { @Guide(description: "材料の名前") let name: String @Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)") let amount: String}
生成結果を「Ingredient配列」として受け取る
次に、呼び出し側で respond(to:generating:) を使い、生成結果の型として [Ingredient] を指定します。これにより、返ってくる結果は文字列ではなく Ingredient の配列として扱えるようになります。
let response = try await session.respond(to: prompt, generating: [Ingredient].self)
この形にしておくと、SwiftUI 側は ForEach(ingredients, id: \.name) のようにそのままリスト表示できるため、コード上で扱いやすくなりました。
全体のコード
import SwiftUIimport FoundationModelsstruct ContentView: View { @State private var text: String = "" @State private var result: [Ingredient] = [] @State private var error: String? = nil @State private var isLoading: Bool = false let model = SystemLanguageModel.default private func send(text: String) async { isLoading = true defer { isLoading = false } do { let instructions = """ あなたは料理の材料を教えてくれるアシスタントです。 ユーザーが入力した料理に必要な材料を返してください。 材料は「材料名」「目安の量」を含めてください。 """ let session = LanguageModelSession(model: model, instructions: instructions) let prompt = """ ユーザーの入力は「\(text)」です。 この料理を作るのに必要な材料を教えてください。 """ let response = try await session.respond(to: prompt, generating: [Ingredient].self) self.result = response.content self.error = nil } catch { self.error = "エラーが発生しました: \(error.localizedDescription)" } } var body: some View { if model.isAvailable { VStack { Text(""" こんにちは! 今から作りたい料理を教えてください! 材料を教えます! """) .padding() .fixedSize(horizontal: false, vertical: true) TextField("作りたいものはなんですか?", text: $text) .textFieldStyle(.roundedBorder) .padding() Button("送る") { Task { await send(text: text) } } .buttonStyle(.borderedProminent) .tint(.black) .disabled(isLoading) if isLoading { ProgressView() } else { ScrollView { if let error { Text(error) } else { ForEach(result, id: \.name) { ingredient in HStack { Text(ingredient.name) .padding(.horizontal) Text(ingredient.amount) .foregroundStyle(.secondary) } .padding(.vertical, 4) } .padding(.horizontal) } } } } } else { Text("Not available") } }}@Generablepublic struct Ingredient { @Guide(description: "材料の名前") let name: String @Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)") let amount: String}
@Generable によって生成結果を型として受け取れるため、UI側はシンプルに実装でき、拡張もしやすい構成になりました。

応用編
@Generableと@Guideを使って材料だけでなく、調理法や難易度なども出力してみたいと思います。
@Guideには以下のように.rangeや.countをつけることによって値を制限することができます。
@Guide(description: "何人前か", .range(1...6))
let servings: Int
これらの機能を使って出力内容の情報を増やしてみます。
@Generablepublic struct Recipe { @Guide(description: "料理名。料理名が読み取れない場合は「料理名を入力してください」。") let title: String @Guide(description: "何人前か", .range(1...6)) let servings: Int @Guide(description: "調理時間(分)", .range(1...180)) let cookTimeMinutes: Int @Guide(description: "難易度(easy/normal/hard のいずれか)") let difficulty: Difficulty @Guide(description: "材料の配列", .count(8)) let ingredients: [Ingredient] @Guide(description: "手順(短文)", .count(6)) let steps: [String]}
コード全体はこちら
import SwiftUIimport FoundationModelsstruct ContentView: View { @State private var text: String = "" @State private var result: Recipe = .init( title: "", servings: 2, cookTimeMinutes: 15, difficulty: .easy, ingredients: [], steps: [] ) @State private var error: String? = nil @State private var isLoading: Bool = false let model = SystemLanguageModel.default private func send(text: String) async { isLoading = true defer { isLoading = false } do { let instructions = """ あなたは料理の材料と手順を教えてくれるアシスタントです。 ユーザーが入力した料理に必要な材料と手順を返してください。 """ let session = LanguageModelSession(model: model, instructions: instructions) let prompt = """ ユーザーの入力は「\(text)」です。 この料理を作るのに必要な材料と手順を教えてください。 """ let response = try await session.respond(to: prompt, generating: Recipe.self) self.result = response.content self.error = nil } catch { self.error = "エラーが発生しました: \(error.localizedDescription)" } } var body: some View { if model.isAvailable { VStack(spacing: 12) { Text(""" こんにちは! 今から作りたい料理を教えてください! 材料と手順を教えます! """) .padding(.top, 8) .fixedSize(horizontal: false, vertical: true) TextField("作りたいものはなんですか?(例: 親子丼)", text: $text) .textFieldStyle(.roundedBorder) .padding(.horizontal) Button("送る") { Task { await send(text: text) } } .buttonStyle(.borderedProminent) .tint(.black) .disabled(isLoading) if isLoading { ProgressView().padding(.top, 8) } if let error { Text(error).foregroundStyle(.red).padding(.horizontal) } else { List { Section("概要") { Text(result.title.isEmpty ? "—" : result.title) HStack { Text("人数") Spacer() Text("\(result.servings)人前").foregroundStyle(.secondary) } HStack { Text("調理時間") Spacer() Text("\(result.cookTimeMinutes)分").foregroundStyle(.secondary) } HStack { Text("難易度") Spacer() Text(result.difficulty.label).foregroundStyle(.secondary) } } Section("材料") { if result.ingredients.isEmpty { Text("—") .foregroundStyle(.secondary) } else { ForEach(result.ingredients, id: \.name) { ingredient in HStack { Text(ingredient.name) Spacer() Text(ingredient.amount) .foregroundStyle(.secondary) } } } } Section("手順") { if result.steps.isEmpty { Text("—") .foregroundStyle(.secondary) } else { ForEach(Array(result.steps.enumerated()), id: \.offset) { idx, step in HStack(alignment: .top, spacing: 8) { Text("\(idx + 1).") .foregroundStyle(.secondary) Text(step) } } } } } } } } else { Text("Not available") } }}@Generablepublic struct Recipe { @Guide(description: "料理名。料理名が読み取れない場合は「料理名を入力してください」。") let title: String @Guide(description: "何人前か", .range(1...6)) let servings: Int @Guide(description: "調理時間(分)", .range(1...180)) let cookTimeMinutes: Int @Guide(description: "難易度(easy/normal/hard のいずれか)") let difficulty: Difficulty @Guide(description: "材料の配列", .count(8)) let ingredients: [Ingredient] @Guide(description: "手順(短文)", .count(6)) let steps: [String]}@Generablepublic struct Ingredient { @Guide(description: "材料の名前") let name: String @Guide(description: "どのくらい購入すればいいかの目安(例: 200g / 2個)") let amount: String}@Generablepublic enum Difficulty: String, CaseIterable { case easy case normal case hard var label: String { switch self { case .easy: return "かんたん" case .normal: return "ふつう" case .hard: return "むずかしい" } }}
完成したアプリ

少し調理手順が怪しい(カレーソースの作り方が斬新)ですが、かなり機能が充実しましたね!
終わりに
今回は、Foundation Models の @Generable を使って、生成結果を文字列ではなく Swift の型(struct)として受け取る実装を試してみました。
まずは respond(to:) で文字列として材料を返すところから始め、次に respond(to: generating:) で [Ingredient] を直接受け取るようにすることで、SwiftUI 側は ForEach でそのまま表示でき、後処理のパースが不要になりました。
さらに応用編では、Recipe(材料+手順+メタ情報)に拡張し、@Guide(.range) や @Guide(.count)、enum を組み合わせることで、出力の揺れを抑えながら情報量を増やすことができました。
生成結果の品質(今回だと調理手順の妥当性)は、プロンプトや @Guide の設計でまだ改善の余地がありますが、「生成AIの結果をアプリのデータとして扱える」という体験はかなり強力でした。
次のステップとしては、材料のカテゴリ分け(野菜/肉/調味料)を型で固定して表示を整える、ユーザー条件(人数・アレルギー・時短など)を入力として追加して精度を上げる、といった方向に拡張していくと実用度がさらに上がりそうです。
ここまで読んでいただき、ありがとうございました。








