สรุปวิธีทำฟอร์ม Type-safe ใน Astro
หัวใจของฟอร์ม Type-safe ไม่ใช่การบังคับให้ FormData เชื่อ type ของเรา แต่คือการยอมรับว่าข้อมูลจาก request ยังไม่น่าเชื่อถือ แล้วแปลงข้อมูลนั้นผ่านฟังก์ชันตรวจสอบเพียงจุดเดียว ก่อนส่งต่อให้โค้ดส่วนอื่น
แนวทางในบทความนี้แบ่งหน้าที่ออกเป็น 4 ชั้น:
| ชั้น | หน้าที่ | ตัวอย่าง |
|---|---|---|
| เบราว์เซอร์ | แจ้งข้อผิดพลาดให้ผู้ใช้เร็วขึ้น | required, type="email" |
| ฟังก์ชัน parser | ตรวจค่าจริงที่เข้ามาในระบบ | typeof, type predicate |
| TypeScript | ตรวจความสอดคล้องขณะพัฒนา | as const, satisfies, union |
| Astro endpoint | รับ request และตอบสถานะที่คาดเดาได้ | 422, error codes |
FormData ทำให้ type หายตรงไหน
เมื่อผู้ใช้ส่งฟอร์ม form.get('email') จะไม่ได้คืน string เสมอไป แต่คืน FormDataEntryValue | null ซึ่งหมายถึง string | File | null ตามชื่อช่องและชนิดข้อมูลที่ถูกส่งมาจริง
แอตทริบิวต์ type="email" ช่วยให้เบราว์เซอร์ตรวจรูปแบบเบื้องต้น แต่ไม่ได้เปลี่ยน type ของค่าที่ endpoint ได้รับ ผู้ใช้หรือระบบอื่นสามารถส่ง request โดยไม่ผ่านฟอร์มหน้าเว็บได้เสมอ
โค้ดแบบนี้จึงซ่อนความเสี่ยงไว้:
const email = form.get('email') as string;
as string ทำให้ TypeScript เลิกเตือน แต่ไม่ได้พิสูจน์ว่าค่าเป็น string หากชื่อช่องผิด ไม่มีค่าถูกส่งมา หรือมีไฟล์เข้ามาแทน ข้อผิดพลาดจะย้ายจาก compile time ไปเกิดตอน runtime
Type บอกว่าโค้ดเชื่ออะไร ส่วน validation พิสูจน์ว่าข้อมูลจริงเป็นอะไร ฟอร์มที่ปลอดภัยต้องมีทั้งสองส่วน
FormDataEntryValue | nullหมายถึง string | File | null — ผู้ส่งไม่จำเป็นต้องมาจากฟอร์มของเราform.get('email') as string- ตอนคอมไพล์
- เชื่อว่าเป็น string
- ตอนรัน
- ยังเป็น File หรือ null ได้
- ผลลัพธ์
- พังทีหลัง ไกลจากจุดที่เป็นต้นเหตุ
typeof value === 'string'- ตอนคอมไพล์
- แคบลงเป็น string ให้เอง
- ตอนรัน
- ตรวจจริงก่อนใช้งาน
- ผลลัพธ์
- ค่าผิดถูกปฏิเสธที่ขอบระบบ พร้อม error code
- string ที่ไม่มีใครตรวจ
- ContactInput ที่ตรวจแล้ว หรือ 422
ประกาศ type และค่าที่อนุญาตไว้ที่เดียว
เริ่มจากโมดูลกลางที่ไม่ผูกกับ Astro เพื่อให้หน้าเว็บ, endpoint และ test ใช้ source of truth ชุดเดียวกัน
// src/lib/feedback.ts
export const TOPICS = ['bug', 'question', 'other'] as const;
export type Topic = (typeof TOPICS)[number];
export type FeedbackInput = {
name: string;
email: string;
topic: Topic;
message: string;
};
export const TOPIC_LABELS = {
bug: 'แจ้งปัญหา',
question: 'สอบถาม',
other: 'เรื่องอื่นๆ',
} satisfies Record<Topic, string>;
as const ทำให้ TOPICS คงค่าเป็น literal union แทนที่จะกว้างเป็น string[] ส่วน satisfies ตรวจว่า TOPIC_LABELS มี label ครบทุก topic โดยไม่ทำให้ type ของ object กว้างเกินจำเป็น
หน้าเว็บสามารถวน TOPICS เพื่อสร้างตัวเลือก และใช้ TOPIC_LABELS[topic] เป็นข้อความที่แสดง เมื่อเพิ่ม topic ใหม่ TypeScript จะชี้ทั้งหน้าฟอร์มและจุดอื่นที่ยังอัปเดตไม่ครบ
แปลง FormData และตรวจค่าที่ runtime
โมดูลเดิมควรมี parser ที่รับข้อมูลดิบ แล้วคืน discriminated union แทนการโยน exception เพราะข้อมูลที่กรอกไม่ครบเป็นผลลัพธ์ปกติของฟอร์ม ไม่ใช่ความผิดพลาดของระบบ
// src/lib/feedback.ts (ต่อ)
type FieldError = 'required' | 'invalid' | 'too-short';
export type ParseResult =
| { ok: true; value: FeedbackInput }
| { ok: false; errors: Partial<Record<keyof FeedbackInput, FieldError>> };
function isTopic(value: string): value is Topic {
return TOPICS.some((topic) => topic === value);
}
function text(form: FormData, field: string): string {
const raw = form.get(field);
return typeof raw === 'string' ? raw.trim() : '';
}
export function parseFeedback(form: FormData): ParseResult {
const name = text(form, 'name');
const email = text(form, 'email');
const topic = text(form, 'topic');
const message = text(form, 'message');
const errors: Partial<Record<keyof FeedbackInput, FieldError>> = {};
if (name.length === 0) errors.name = 'required';
if (!email.includes('@')) errors.email = 'invalid';
if (!isTopic(topic)) errors.topic = 'invalid';
if (message.length < 10) errors.message = 'too-short';
if (!isTopic(topic) || Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, value: { name, email, topic, message } };
}
ฟังก์ชัน text() ปิดกรณี File และ null ก่อนเรียก .trim() ส่วน isTopic() เป็น type predicate ที่ตรวจค่าตอน runtime พร้อมช่วยให้ TypeScript แคบ topic จาก string เหลือ Topic
ตัวอย่างตรวจอีเมลด้วย includes('@') มีไว้ให้เห็นโครงสร้างของ parser เท่านั้น ระบบจริงควรกำหนดนโยบายความถูกต้องให้เหมาะกับงาน หรือใช้ schema validation library เมื่อฟอร์มซับซ้อนขึ้น
ต่อ Astro endpoint อย่างถูกต้อง
เมื่อ parser รับผิดชอบการตรวจข้อมูลแล้ว endpoint จะเหลือหน้าที่รับ request, เรียก parser และตอบสถานะ HTTP
// src/pages/api/feedback.ts
import type { APIRoute } from 'astro';
import { parseFeedback } from '../../lib/feedback';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const result = parseFeedback(await request.formData());
if (!result.ok) {
return Response.json({ errors: result.errors }, { status: 422 });
}
// บันทึก result.value ซึ่งเป็น FeedbackInput ที่ผ่านการตรวจแล้ว
return new Response(null, { status: 204 });
};
หากโปรเจกต์ใช้โหมด static ตามค่าเริ่มต้น เส้นทางนี้ต้องมีทั้ง export const prerender = false และ Astro adapter ที่รองรับ runtime เป้าหมาย การตั้ง prerender เพียงอย่างเดียวไม่ได้เพิ่ม server runtime ให้โปรเจกต์
หากระบบรับฟอร์มผ่าน Worker หรือ backend แยกต่างหาก ไม่จำเป็นต้องสร้าง Astro API route นี้ แต่ควรคงหลักการเดิม: ตรวจ request ที่ขอบเขตของระบบก่อนใช้ข้อมูล และแชร์ type/parser เท่าที่ runtime ของทั้งสองฝั่งรองรับ
ส่ง error code ให้หน้าเว็บแปลเอง
กำหนดรูปแบบ error response ให้คงที่ เพื่อให้หน้าเว็บจับคู่ข้อผิดพลาดกับช่องกรอกได้โดยไม่ต้องเดา
{
"errors": {
"email": "invalid",
"message": "too-short"
}
}
ใช้รหัสที่คงที่แทนประโยคสำเร็จรูป แล้วให้ชั้นแสดงผลแปลเป็นข้อความไทยหรืออังกฤษ วิธีนี้ทำให้ UX copy เปลี่ยนได้โดยไม่กระทบ API และช่วยให้ test ตรวจผลลัพธ์ได้ชัดเจน
ข้อความที่แสดงควรบอกทั้งปัญหาและวิธีแก้ เช่น “กรอกอีเมลให้ครบในรูปแบบ name@example.com” ชัดกว่าคำว่า “ข้อมูลไม่ถูกต้อง” และควรผูกข้อความกับช่องกรอกด้วย aria-describedby เพื่อให้โปรแกรมอ่านหน้าจอรับรู้
ให้ browser validation ช่วยรอบแรก
การตรวจฝั่งเซิร์ฟเวอร์คือด่านที่เชื่อถือได้ ส่วน browser validation มีไว้ช่วยให้ผู้ใช้แก้ข้อมูลได้เร็วขึ้นและลด request ที่ไม่จำเป็น ใช้แอตทริบิวต์มาตรฐานก่อนเพิ่ม JavaScript:
requiredสำหรับช่องที่ต้องกรอกtype="email"และtype="url"สำหรับรูปแบบพื้นฐานminlengthและmaxlengthสำหรับความยาวข้อความpatternเมื่อมีกติกาเฉพาะ โดยต้องมีกติกาเดียวกันที่ฝั่งเซิร์ฟเวอร์ด้วย
อย่าใช้สีเพียงอย่างเดียวเพื่อบอก error ควรมีข้อความกำกับ, focus ที่ชัดเจน และพาผู้ใช้ไปยังช่องแรกที่ต้องแก้ อ่านรายละเอียดได้จาก คู่มือ client-side form validation ของ MDN
ลำดับ implementation ที่แนะนำ
- ประกาศค่าที่อนุญาต, type และ label map ไว้ในโมดูลเดียว
- ให้หน้าเว็บสร้างตัวเลือกจากค่าชุดนั้น แทนการพิมพ์ string ซ้ำ
- เขียน parser ที่ตรวจ
null,File, รูปแบบ และข้อจำกัดทางธุรกิจ - ให้ endpoint รับเฉพาะค่าที่ parser คืนในกรณี
ok: true - ส่ง error code ที่คงที่ แล้วแปลเป็นข้อความที่ชั้นแสดงผล
- ทดสอบทั้งข้อมูลที่ถูกต้อง, ช่องหาย, ค่าที่ไม่อยู่ในรายการ และไฟล์ที่ถูกส่งเข้าช่องข้อความ
คำถามที่พบบ่อย
มี TypeScript แล้ว ยังต้องตรวจข้อมูลฝั่งเซิร์ฟเวอร์หรือไม่
ต้องตรวจ TypeScript ทำงานขณะพัฒนาและไม่สามารถรับรองค่าที่มาจาก browser, bot หรือ API client ได้ ข้อมูลจาก request จึงต้องถือว่ายังไม่น่าเชื่อถือจนกว่า parser จะยืนยัน
จำเป็นต้องใช้ Zod หรือ schema library หรือไม่
ไม่จำเป็นสำหรับฟอร์มขนาดเล็กที่กติกาตรงไปตรงมา แต่ library ช่วยลดโค้ดซ้ำและจัดการ nested data, transformation หรือกติกาที่ซับซ้อนได้ดีขึ้น ไม่ว่าจะเลือกวิธีใด หลักสำคัญคือมี runtime validation ที่ขอบเขตของระบบ
เว็บไซต์ Astro แบบ static รับ POST request ได้หรือไม่
ไฟล์ HTML ที่สร้างไว้ล่วงหน้าไม่สามารถประมวลผล request body เองได้ หากต้องการใช้ Astro API route ต้องติดตั้ง adapter และกำหนด route เป็น on-demand ด้วย prerender = false อีกทางคือส่งฟอร์มไปยัง Worker หรือ backend ภายนอก
ทำไมไม่ใช้ as string ให้โค้ดสั้นกว่า
เพราะ as string ไม่มีการตรวจค่าและอาจซ่อน null หรือ File ไว้จนเกิด error ภายหลัง ฟังก์ชัน parser ทำให้จุดที่รับข้อมูลดิบมองเห็นได้ ทดสอบได้ และส่งต่อเฉพาะข้อมูลที่ผ่านเงื่อนไข

