昨天我們終於讓 Gemini 一次回傳 5 張 Learning Cards,而且資料不再是一大段文字,而是有固定結構的 JSON:
{
"cards": [
{
"emoji": "🐢",
"title": "認識海龜",
"content": "...",
"question": "...",
"answer": "..."
}
]
}
做到這裡,Gemini 已經知道 Learning Card 應該長什麼樣子了。
但身為前端工程師,看到這份 JSON 之後,我腦中馬上又出現另一個問題:
Gemini 知道資料格式了,那 TypeScript 知道嗎?
所以今天先不碰 Prompt,也不繼續調整 Gemini,而是回到比較熟悉的 TypeScript 世界。
前幾天在 Structured Output 裡,我們定義了一張 Learning Card 需要包含:
emoji
title
content
question
answer
而且這五個欄位都是 string。
到了 TypeScript,其實可以直接把同樣的資料結構定義成:
type LearningCard = {
emoji: string;
title: string;
content: string;
question: string;
answer: string;
};
這樣一來,TypeScript 就知道:
一筆
LearningCard應該有哪些欄位,以及每個欄位是什麼型別。
把前幾天的 Schema 和今天的 TypeScript 放在一起看,其實兩邊正在描述同一份資料:
Gemini Schema TypeScript
emoji: string → emoji: string
title: string → title: string
content: string → content: string
question: string → question: string
answer: string → answer: string
只是它們負責的階段不同。
Structured Output 的 Schema 用來定義 Gemini 應該產生什麼樣的資料結構;TypeScript Type 則讓程式知道自己預期使用什麼樣的資料。
cards昨天我們最後設計的 Response 並不是直接回傳 Learning Card Array,而是:
{
"cards": [...]
}
因此除了單張卡片的 LearningCard,還需要再定義整個 Response:
type LearningCardResponse = {
cards: LearningCard[];
};
這裡的:
LearningCard[]
就是由多個 LearningCard 組成的 Array。
所以 Day 12 的資料結構:
Response
└── cards: Array
└── Learning Card Object
到了 TypeScript 就變成:
LearningCardResponse
└── cards: LearningCard[]
昨天才剛處理完 Array,今天馬上就派上用場了。
接下來我直接把昨天 Gemini 產生的資料放進 index.ts,並指定它的型別為 LearningCardResponse:
type LearningCard = {
emoji: string;
title: string;
content: string;
question: string;
answer: string;
};
type LearningCardResponse = {
cards: LearningCard[];
};
const response: LearningCardResponse = {
cards: [
{
emoji: "🐢",
title: "認識海龜",
content:
"海龜住在美麗的大海裡。牠的背上背著一個又圓又硬的大殼,就像隨身帶著堅固的小房子一樣喔!",
question: "海龜背上背著什麼呢?",
answer: "硬硬的大殼。",
},
{
emoji: "🐋",
title: "認識鯨魚",
content:
"鯨魚是世界上體型最大的動物,牠在水面上呼氣的時候,頭頂會噴出高高的水花喔!",
question: "鯨魚呼氣時,頭頂會噴出什麼呢?",
answer: "高高的水花。",
},
{
emoji: "🐙",
title: "認識章魚",
content:
"章魚的身體軟綿綿的,牠有八隻長長的腳,遇到危險時還會噴出黑黑的墨汁逃跑喔!",
question: "章魚有幾隻長長的腳呢?",
answer: "八隻腳。",
},
{
emoji: "🦀",
title: "認識螃蟹",
content:
"螃蟹有硬硬的外殼和一對像剪刀的大鉗子,而且牠們走路時都是橫著走的喔!",
question: "螃蟹走路是向前走還是橫著走呢?",
answer: "橫著走。",
},
{
emoji: "🐠",
title: "認識小丑魚",
content:
"小丑魚身上有漂亮橘色和白色的條紋,最喜歡躲在軟綿綿的海葵家裡面玩捉迷藏喔!",
question: "小丑魚最喜歡躲在哪裡呢?",
answer: "軟綿綿的海葵家裡。",
},
],
};
console.log(response);
接著使用 TypeScript compiler 做型別檢查:
npx tsc --noEmit index.ts
這裡的 --noEmit 可以簡單理解成:
幫我做 TypeScript 型別檢查,但先不要輸出編譯後的 JavaScript。
環境準備好之後,就可以開始今天的三組小實驗。
第一輪先維持正常的 Learning Card:
{
emoji: "🐢",
title: "認識海龜",
content: "海龜住在美麗的大海裡...",
question: "海龜背上背著什麼呢?",
answer: "硬硬的大殼。",
}
執行:
npx tsc --noEmit index.ts
Terminal 沒有出現任何 TypeScript Error。
也就是:
LearningCard
├── emoji ✓
├── title ✓
├── content ✓
├── question ✓
└── answer ✓
No TypeScript errors
第一關順利通過。
answer 呢?第二輪開始故意把資料弄壞看看。
我把第一張 Learning Card 的 answer 拿掉:
{
emoji: "🐢",
title: "認識海龜",
content: "海龜住在美麗的大海裡...",
question: "海龜背上背著什麼呢?",
}
但原本定義的 LearningCard 仍然是:
type LearningCard = {
emoji: string;
title: string;
content: string;
question: string;
answer: string;
};
這時 VS Code 馬上出現紅色波浪線,實際收到的錯誤是:
Property 'answer' is missing in type
'{ emoji: string; title: string; content: string; question: string; }'
but required in type 'LearningCard'. ts(2741)
'answer' is declared here.
TypeScript 很直接地告訴我:
LearningCard規定需要answer,但是目前這筆資料沒有。
也就是:
LearningCard
├── emoji ✓
├── title ✓
├── content ✓
├── question ✓
└── answer ✗ Missing
看到這裡,很容易聯想到 Day 11 在 Schema 裡設定的:
"required": [
"emoji",
"title",
"content",
"question",
"answer"
]
Structured Output 用 required 描述 Gemini 輸出時哪些欄位必須存在;到了 TypeScript,我們則透過 LearningCard 定義程式預期有哪些欄位。
第三輪再換一種方式。
這次不刪除 answer,而是把原本的:
answer: "硬硬的大殼。",
故意改成:
answer: 123,
answer 還在,但現在從 string 變成了 number。
TypeScript 這次出現:
Type 'number' is not assignable to type 'string'. ts(2322)
The expected type comes from property 'answer'
which is declared here on type 'LearningCard'
原因也很明確。
我們定義的是:
answer: string;
實際給的卻是:
answer: 123;
所以現在變成:
answer
預期:string
實際:number
↓
TypeScript Error
這次 TypeScript 不只知道 Learning Card「有哪些欄位」,還知道:
每個欄位應該是什麼資料型別。
今天三次實驗的結果就很清楚了:
| 實驗 | answer |
TypeScript |
|---|---|---|
| Run #1 | "硬硬的大殼。" |
✅ No errors |
| Run #2 | 缺少 answer |
❌ Property answer is missing |
| Run #3 | 123 |
❌ number is not assignable to string |
所以建立 Type 的目的,不只是幫 JSON 取一個叫 LearningCard 的名字。
我們其實是在告訴 TypeScript:
LearningCard
│
├── 要有哪些欄位?
│
└── 每個欄位是什麼型別?
當程式裡使用的資料不符合這份定義時,TypeScript 就能在開發階段提醒我們。
這也是我平常寫 TypeScript 時很習慣的一件事,但放到這次 Gemini 的資料流程裡,就更能看出它的位置。
做到這裡,前幾天的 Structured Output 和今天的 TypeScript 終於可以串起來了。
目前整個流程大概是:
Learning Requirement
↓
Gemini
↓
Structured Output Schema
↓
JSON Response
↓
LearningCardResponse
↓
LearningCard[]
Schema 和 TypeScript 看起來都在描述資料結構,但負責的事情不太一樣。
可以先簡單理解成:
Structured Output
→ 定義 Gemini 應該產生什麼樣的資料結構
TypeScript
→ 定義前端程式預期怎麼使用這些資料
前面幾天一直在處理「AI 怎麼回資料」,今天則開始進入「程式怎麼接這份資料」。
終於慢慢回到前端工程師的主場了(笑)。
這裡還有一件事情不能搞混。
今天建立:
type LearningCardResponse = {
cards: LearningCard[];
};
並不代表以後從 Gemini API 回來的任何資料,TypeScript 都會自動幫我們確認它一定符合這個結構。
例如之後真的開始串 API:
const response = await fetch("...");
const data = await response.json();
這些資料是在程式執行時才從外部取得的。
而今天建立的 TypeScript Type,主要是在開發階段幫助我們描述與檢查程式中的資料。
所以:
TypeScript Type
≠
Runtime Validator
今天先知道這個差別就好。
至於要不要再做 Runtime Validation……
這個坑今天先不要挖,不然只是想做五張學習卡,最後可能連 Zod 都跑出來了(笑)。
今天沒有繼續修改 Prompt,也沒有重新測試 Gemini,而是把昨天取得的 JSON 正式帶進 TypeScript。
我們建立了:
type LearningCard = {
emoji: string;
title: string;
content: string;
question: string;
answer: string;
};
type LearningCardResponse = {
cards: LearningCard[];
};
並且實際做了三次測試。
正常資料可以順利通過型別檢查;少了必要的 answer,TypeScript 會提醒欄位缺失;把 answer 從 string 改成 number,也會直接告訴我們型別不符合。
從 Day 09 開始,我們一路從一段「人看得懂的文字」,慢慢整理成:
文字
↓
JSON
↓
Schema
↓
Array
↓
TypeScript Type
現在 Learning Card 已經不只是一段 Gemini 產生的 JSON,而是開始變成前端程式真正可以理解與使用的資料。
照以前的開發方式,下一步大概就是開一個 React 專案,開始建 Component、寫樣式,再慢慢把 Learning Card 畫面刻出來。
但都已經做到「Build on Google AI」了,我突然想到另一個問題:
現在的第一版 UI,真的還需要全部自己從零開始刻嗎?
既然 AI 可以幫我們產生學習內容,那前端 UI 是不是也可以先交給 AI 試試看?
Day 14|現在還需要自己刻前端嗎?讓 AI 幫我建立第一版 Learning Card UI