สรุปวิธีทำฟอร์ม 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 พิสูจน์ว่าข้อมูลจริงเป็นอะไร ฟอร์มที่ปลอดภัยต้องมีทั้งสองส่วน

ค่าเดียวกัน สองเส้นทางทั้งสองเส้นทางคอมไพล์ผ่านเหมือนกัน ต่างกันตอน request จริงเข้ามา
ค่าที่ได้จาก requestFormDataEntryValue | 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 ที่แนะนำ

  1. ประกาศค่าที่อนุญาต, type และ label map ไว้ในโมดูลเดียว
  2. ให้หน้าเว็บสร้างตัวเลือกจากค่าชุดนั้น แทนการพิมพ์ string ซ้ำ
  3. เขียน parser ที่ตรวจ null, File, รูปแบบ และข้อจำกัดทางธุรกิจ
  4. ให้ endpoint รับเฉพาะค่าที่ parser คืนในกรณี ok: true
  5. ส่ง error code ที่คงที่ แล้วแปลเป็นข้อความที่ชั้นแสดงผล
  6. ทดสอบทั้งข้อมูลที่ถูกต้อง, ช่องหาย, ค่าที่ไม่อยู่ในรายการ และไฟล์ที่ถูกส่งเข้าช่องข้อความ

คำถามที่พบบ่อย

มี 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 ทำให้จุดที่รับข้อมูลดิบมองเห็นได้ ทดสอบได้ และส่งต่อเฉพาะข้อมูลที่ผ่านเงื่อนไข

แหล่งอ้างอิงและอ่านต่อ