โหมดมืด
บทที่ 11 — TypeScript ในงานจริง
⏱ ใช้เวลา 3–4 ชั่วโมง
บทสุดท้าย — เอาความรู้ทั้งหมดไปใช้จริง
10 บทที่ผ่านมา เราเรียน "ตัวภาษา" TypeScript จนครบ บทสุดท้ายนี้คือการ "เชื่อม" ความรู้ทั้งหมดเข้ากับงานจริง — เพราะในชีวิตการทำงาน คุณไม่ได้เขียน TypeScript ลอย ๆ แต่เขียนมันคู่กับ React, Node.js, การตรวจสอบข้อมูล, การทดสอบ ฯลฯ
บทนี้จะครอบคลุม:
- TypeScript กับ React
- TypeScript กับ Node.js (backend)
- การแชร์ type ระหว่าง frontend และ backend
- Zod — ตรวจสอบข้อมูล runtime (เราเกริ่นมาตลอดเล่ม ถึงเวลาใช้จริง)
- การย้ายโปรเจกต์จาก JavaScript มา TypeScript
- เครื่องมือ — linter, การทดสอบ
- รวมข้อผิดพลาดที่พบบ่อย และ checklist สรุป
📌 เวอร์ชันที่ใช้อ้างอิงในบทนี้ (สถานะ 2026)
- TypeScript 5.7+ — ใช้ฟีเจอร์ใหม่ เช่น
--strict,noUncheckedIndexedAccess,verbatimModuleSyntax- Node.js 22–24 LTS — รัน TypeScript ตรงได้โดยไม่ต้อง compile ก่อน: Node 22 LTS ต้องใส่ flag
--experimental-strip-types; Node 24 LTS เปิด strip-types ไว้ให้โดยอัตโนมัติ (ไม่ต้องใส่ flag) — หรือใช้ tsx/ts-node แทนก็ได้- React 19 — มี Server Components,
use()hook, form actions (ตัวอย่างในบทนี้เน้นพื้นฐานที่ยังใช้ได้ทุกเวอร์ชัน)- ESM module — โปรเจกต์ใหม่นิยมตั้ง
"type": "module"ในpackage.json— ต้องระวังเรื่อง.jsextension ใน import (อธิบายใน Part 6)- Zod v4, tRPC v11 — เวอร์ชันที่ใช้ในตัวอย่าง
📌 บทนี้มีหลายส่วนที่ต้องการความรู้เสริม: Part 1 ต้องการพื้นฐาน React (component, JSX, hooks), Part 2 ต้องการพื้นฐาน Node.js/Express — ถ้ายังไม่มีพื้นฐานส่วนนั้น ข้ามไปส่วนที่รู้แล้วได้ก่อน แล้วค่อยกลับมาเมื่อเรียนส่วนนั้นครบ
Part 1: TypeScript + React
💡 ผู้ที่ไม่ใช้ React ข้ามไป Part 2 ได้เลย — ส่วนนี้สมมติว่าคุณรู้พื้นฐาน React (component, JSX, hooks) มาบ้าง ถ้ายังไม่เคยใช้ ไม่ต้องตำหนิตัวเอง — กลับมาอ่านเมื่อเริ่มเรียน React ตัวอย่างทุกชิ้นต้อง
import { useState, useRef } from "react"(และ React 19 ก็มี hooks ใหม่ เช่นuse()ที่บทนี้ไม่ลงลึก)
React สมัยใหม่ใช้ TypeScript เป็นมาตรฐาน มาดูจุดที่ต้องใส่ type
1.1 ใส่ type ให้ Props ของ Component
Component คือฟังก์ชันที่รับ "props" (ข้อมูลขาเข้า) — เราใส่ type ให้ props เหมือนใส่ type ให้พารามิเตอร์ฟังก์ชันปกติ:
tsx
type GreetingProps = {
name: string;
age?: number; // optional
onGreet: () => void; // props ที่เป็นฟังก์ชัน
};
function Greeting({ name, age, onGreet }: GreetingProps) {
return (
<button onClick={onGreet}>
สวัสดี {name} {age && `(อายุ ${age})`}
</button>
);
}สังเกต — GreetingProps เป็นแค่ object type ธรรมดา (จากบทที่ 04) ไม่มีอะไรพิเศษ React แค่เอามาใช้กับ component
1.2 ใส่ type ให้ Hooks
Hooks ของ React หลายตัวเป็น generic — เราเติม type เข้าไปได้:
tsx
import { useState, useRef } from "react"; // อย่าลืม import
// useState — TypeScript เดา type จากค่าเริ่มต้นได้
const [count, setCount] = useState(0); // count เป็น number (เดาเอง)
// แต่ถ้าค่าเริ่มต้นเป็น null ต้องระบุ type เอง
const [user, setUser] = useState<User | null>(null);
// ↑ ระบุว่า state นี้เป็น User หรือ null
// useRef — HTMLInputElement เป็น type ของ DOM element ที่ React มีให้
// (ผู้ที่ยังไม่คุ้น React ข้ามได้)
const inputRef = useRef<HTMLInputElement>(null);จุดที่ต้องระวัง — ถ้าค่าเริ่มต้นไม่บอก type ที่แท้จริง (เช่น null, []) ต้องระบุ generic เอง ไม่งั้น TypeScript จะเดาแคบเกินไป
1.3 ใส่ type ให้ Event
เวลาจัดการ event (คลิก, พิมพ์, submit) React มี type ของ event ให้:
tsx
function Form() {
// React.FormEvent / React.ChangeEvent คือ type ของ event ที่ React มีให้
// (ดูเร็ว ๆ ก็พอ — บทลึก ๆ ของ React จะอธิบายแต่ละตัว)
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
console.log(event.target.value);
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
</form>
);
}💡 เคล็ดลับ: ส่วนใหญ่คุณไม่ต้องจำชื่อ type ของ event — ถ้าเขียน handler ไว้ใน JSX โดยตรง (เช่น
onClick={(e) => ...}) React จะ "เดา type ของeให้เอง" — เรียกพฤติกรรมนี้ว่า contextual typing (เดา type จากบริบทรอบข้าง) คุณค่อยใส่ type เองเมื่อต้องแยก handler ออกมาเป็นฟังก์ชันต่างหาก (เพราะตอนนั้นไม่มีบริบทแล้ว — TypeScript เดาให้ไม่ได้)
📖 เรื่อง TypeScript + React แบบลึก (เช่น
React.FormEvent,React.ChangeEventในตัวอย่างข้างบน) มีบทเฉพาะที่ react/13 — TypeScript Deep for React (ถ้าเล่ม React ยังไม่พร้อม ข้ามไปก่อนได้)
Part 2: TypeScript + Node.js (Backend)
ฝั่ง server เราใช้ TypeScript กับ framework อย่าง Express มาดูตัวอย่าง
2.1 ใส่ type ให้ Express route
typescript
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
// กำหนด type ของข้อมูลที่ client ส่งมา
type CreateUserBody = {
name: string;
email: string;
};
app.post("/users", (req: Request<{}, {}, CreateUserBody>, res: Response) => {
// ↑ ↑ ↑
// Params ResBody ReqBody (body ที่ client ส่งมา)
const { name, email } = req.body; // req.body มี type เป็น CreateUserBody
res.json({ message: `สร้างผู้ใช้ ${name} แล้ว` });
});(generic ของ Request<Params, ResBody, ReqBody> มี 3 ช่อง — ช่องที่ 1 คือ URL params, ช่องที่ 2 คือ response body, ช่องที่ 3 คือ type ของ req.body)
💡
{}ตรงนี้คืออะไร: สังเกตว่าช่องที่ 1 และ 2 ใส่{}ไว้ — ในบริบทนี้{}แค่หมายถึง "เราไม่ได้ระบุ type ให้ช่องนี้ ปล่อยผ่านไปก่อน" (ไม่ได้แปลว่า "ต้องเป็น object ว่าง") เป็นการบอก Express ว่ายังไม่สนใจ type ของ URL params และ response body ในตัวอย่างนี้ — ยังต่างจาก{}ที่เป็น type ทั่วไป (ซึ่งหมายถึง "ค่าอะไรก็ได้ที่ไม่ใช่null/undefined" ตามที่เรียนในบทที่ 02)
2.2 type ให้ Environment Variables
เราเรียนวิธีนี้ไปแล้วในบทที่ 09 — ทำให้ process.env มี type:
typescript
// ไฟล์: env.d.ts
export {}; // ★ ทำให้ไฟล์เป็น module — ต้องมีบรรทัดนี้ก่อน declare global (กฎเดียวกับบทที่ 09)
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
PORT: string;
NODE_ENV: "development" | "production";
}
}
}📌 อย่าลืม
export {}+declare globalตามกฎเหล็กจากบทที่ 09 — ถ้าไฟล์นี้ดันมีimport/exportอื่นในภายหลัง (กลายเป็น module file) แต่ลืมห่อด้วยdeclare globalการประกาศจะไม่ merge เข้ากับNodeJS.ProcessEnvจริง ใช้ pattern นี้เสมอเพื่อความปลอดภัย
Part 3: แชร์ Type ระหว่าง Frontend และ Backend
นี่คือหนึ่งในประโยชน์ที่ยิ่งใหญ่ที่สุดของ TypeScript — เมื่อทั้ง frontend และ backend เขียนด้วย TypeScript เราสามารถ ใช้ type ชุดเดียวกัน ทั้งสองฝั่ง
3.1 ปัญหาที่มันแก้
ในระบบทั่วไป frontend กับ backend คุยกันผ่าน API ถ้าวันหนึ่ง backend เปลี่ยนรูปแบบข้อมูล (เช่น เปลี่ยน field name เป็น fullName) — frontend จะไม่รู้ตัว จนกระทั่งผู้ใช้เจอบั๊ก
ถ้าทั้งสองฝั่งใช้ type ชุดเดียวกัน — พอ backend เปลี่ยน type frontend จะ error ทันทีตอน compile ก่อนถึงมือผู้ใช้
3.2 วิธีที่ 1 — แชร์ type ผ่าน shared package (Monorepo)
💡 หัวข้อนี้เน้นโปรเจกต์ขนาดใหญ่ — โปรเจกต์เล็กที่ frontend/backend อยู่คนละ repo อาจไม่จำเป็นต้องตั้ง monorepo อ่านผ่าน ๆ ดูคอนเซปต์ก่อนได้
จัดโครงสร้างโปรเจกต์แบบ monorepo (โมโนรีโป—ที่เก็บโค้ดที่รวมหลาย package ไว้ในที่เดียวกัน) — มี package (แพ็กเกจ—หน่วยโค้ดที่แยกออกมาเป็นชิ้น ใช้ร่วมกันได้) กลางที่เก็บ type:
text
my-project/
├── packages/
│ ├── shared/ ← type กลาง อยู่ที่นี่
│ │ └── types.ts
│ ├── backend/ ← ใช้ type จาก shared
│ └── frontend/ ← ใช้ type จาก sharedtypescript
// packages/shared/types.ts
export type User = {
id: number;
name: string;
email: string;
};ทั้ง backend และ frontend import type { User } from "shared" — ใช้ type เดียวกันเป๊ะ
3.3 วิธีที่ 2 — tRPC (type-safe แบบไม่ต้อง generate)
tRPC (อ่านว่า "ที-อาร์-พี-ซี") ย่อมาจาก Type-safe Remote Procedure Call — เทคโนโลยีที่ให้ฝั่ง client เรียกฟังก์ชันของ server ได้โดยตรง พร้อม type safety แบบ end-to-end ผลคือ frontend จะรู้จัก API ของ backend โดยอัตโนมัติ ไม่ต้องเขียน type ซ้ำ ไม่ต้อง generate อะไรเลย เวอร์ชันที่อ้างอิงในบทนี้คือ tRPC v11:
💡 อ่านผ่าน ๆ ดูคอนเซปต์ก่อนได้ —
z.object,publicProcedure,routerเป็น API เฉพาะของ tRPC + Zod ตอนใช้จริงค่อยเปิด docs ที่ trpc.io และติดตั้งด้วยnpm install @trpc/server @trpc/client
typescript
// ฝั่ง server — นิยาม API
// (import ของ router / publicProcedure / z ถูกละไว้เพื่อความกระชับ — ดูวิธี setup เต็มที่ trpc.io)
const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() })) // validate input ด้วย Zod
.query(({ input }) => {
return { id: input.id, name: "Alice" };
}),
});
export type AppRouter = typeof appRouter; // export แค่ "type" ของ routertypescript
// ฝั่ง client — import แค่ type
import type { AppRouter } from "../server";
const trpc = createTRPCClient<AppRouter>();
const user = await trpc.getUser.query({ id: "1" });
// user มี type ครบ — TypeScript รู้เองว่ามี id, name
// ถ้าใส่ input ผิด หรือ server เปลี่ยน → error ทันทีความเจ๋ง: client import type แค่ "type" ของ router (ซึ่งหายตอน compile) — ไม่ได้ดึงโค้ด server มาเลย แต่ได้ type safety เต็มที่
💡 ทางเลือกอื่นที่น่ารู้ (สถานะ 2026) — ถ้าสนใจทางเลือกอื่นนอกจาก tRPC มีตัวที่นิยมในปี 2026 ดังนี้ — อ่านผ่าน ๆ ก็พอ ไม่ต้องจำทั้งหมด:
- Hono RPC — Hono เป็น web framework เบา ๆ ที่รันได้บนหลาย runtime (สภาพแวดล้อมที่ใช้รันโค้ด JavaScript): Node.js, Bun (runtime ทางเลือกจากผู้สร้างรายใหม่), Deno (อีก runtime ทางเลือก), และ Edge runtime (รัน JS ใกล้ผู้ใช้บน CDN) — ใช้
hc<typeof app>()ฝั่ง client ได้ type safety แบบ tRPC- ts-rest / oRPC — ทางเลือก type-safe อื่น ๆ ที่ออกแบบให้คล้าย REST มากกว่า
- OpenAPI + generator — ถ้าทีมมี backend ภาษาอื่น (Java, Go, Python) ใช้ OpenAPI spec (ไฟล์อธิบาย API มาตรฐาน) แล้ว generate type ฝั่ง TS ได้ (เช่น
openapi-typescript)
Part 4: Zod — ตรวจสอบข้อมูล Runtime
เรากล่าวถึง Zod มาตลอดทั้งเล่ม ถึงเวลาใช้จริงเต็ม ๆ
4.1 ทบทวนว่าทำไมต้องมี Zod
จำบทที่ 00 ได้ไหม — type ของ TypeScript หายไปตอน runtime (type erasure) ดังนั้นข้อมูลที่มาจากภายนอก (API, ฟอร์ม, ไฟล์) TypeScript ตรวจสอบให้ไม่ได้:
typescript
const user: User = await fetch("/api/user").then(r => r.json());
// TypeScript "เชื่อ" ว่าเป็น User — แต่ถ้า server ส่งข้อมูลผิดมา โปรแกรมจะพังตอน runtimeZod แก้ปัญหานี้ — มันตรวจสอบข้อมูล จริง ๆ ตอน runtime
4.2 พื้นฐาน Zod
Zod เป็น library ตรวจสอบข้อมูลตอน runtime (เช่น response จาก API ที่ TypeScript เชื่อไม่ได้ว่าตรง type จริง) — ประกาศ "schema" ของข้อมูลแล้ว Zod ทั้ง validate และ infer type ให้ ปิดช่องว่างระหว่าง compile-time กับ runtime เวอร์ชันที่อ้างอิงในบทนี้คือ Zod v4 (API ส่วนใหญ่ตรงกับ v3 — แต่ v4 ปรับ error format และเร็วขึ้น):
typescript
import { z } from "zod";
// 1. ประกาศ "schema" — กฎว่าข้อมูลต้องหน้าตาแบบไหน
const UserSchema = z.object({
id: z.number(),
name: z.string().min(2), // string ยาวอย่างน้อย 2 ตัว
email: z.string().email(), // ต้องเป็นรูปแบบอีเมล
age: z.number().min(0).max(150).optional(), // ตัวเลข 0-150 ไม่บังคับ
role: z.enum(["admin", "user", "guest"]), // ต้องเป็นค่าใดค่าหนึ่งใน 3 ตัว
});
// 2. ดึง type ของ TypeScript ออกจาก schema — ไม่ต้องเขียน type ซ้ำ!
type User = z.infer<typeof UserSchema>;4.3 ตรวจสอบข้อมูลจริง
Zod มี 2 วิธีตรวจ:
typescript
// วิธีที่ 1 — parse() — ถ้าข้อมูลผิด จะ "โยน error"
const user = UserSchema.parse(rawData); // ถ้าผ่าน user มี type เป็น User
// วิธีที่ 2 — safeParse() — คืน result ไม่โยน error (จัดการเองได้)
const result = UserSchema.safeParse(rawData);
if (result.success) {
console.log(result.data); // ข้อมูลที่ผ่านการตรวจ มี type เป็น User
} else {
console.log(result.error); // รายละเอียดว่าผิดตรงไหน
}result ของ safeParse เป็น discriminated union (จากบทที่ 04/07) — result.success คือ discriminant: ถ้า true TypeScript รู้ว่า result.data มีค่า ถ้า false TypeScript รู้ว่า result.error มีค่า — ดูบทที่ 07 ถ้าต้องการทบทวน
4.4 ใช้จริง — ตรวจข้อมูลที่ API รับมา
typescript
app.post("/users", (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
// ข้อมูลที่ client ส่งมาไม่ถูกต้อง — ตอบ error
return res.status(400).json({ error: "ข้อมูลไม่ถูกต้อง" });
}
const user = result.data; // ตรงนี้ปลอดภัยแล้ว — มี type เป็น User
// ...บันทึก user ลงฐานข้อมูล
});🎯 กฎทอง: ทุกจุดที่ข้อมูล "เข้าสู่โปรแกรม" จากภายนอก — API request, response, ฟอร์ม,
localStorage, ตัวแปร env — ให้ ตรวจด้วย Zod อย่าเชื่อasหรือเชื่อ type ลอย ๆ นี่คือเส้นแบ่งระหว่างโปรแกรมที่ "type-safe จริง" กับ "type-safe แค่บนกระดาษ"
💡 ทางเลือกอื่น (สถานะ 2026) — Zod ไม่ใช่ตัวเลือกเดียว:
- Valibot — API คล้าย Zod แต่ออกแบบให้ tree-shake (ตัดโค้ดที่ไม่ใช้ออก) ได้ดีกว่า ทำให้ bundle (ไฟล์รวมที่ส่งให้เบราว์เซอร์) เล็กมาก เหมาะกับ frontend ที่แคร์ขนาดไฟล์
- arktype — ใช้ syntax คล้าย TypeScript type เลย (
type({ name: "string" })) เร็วและ infer type ได้สวย- Effect Schema — เป็นส่วนหนึ่งของ Effect ecosystem (กลุ่ม library สไตล์ functional programming) เหมาะถ้าทีมใช้ Effect อยู่แล้ว
- TypeBox — ใช้ JSON Schema เป็นพื้น เหมาะถ้าต้องการสร้าง OpenAPI spec (ไฟล์อธิบาย API มาตรฐาน)
เลือกตัวที่เข้ากับทีมและโปรเจกต์ — แนวคิด "validate ที่ขอบ" เหมือนกันทุกตัว
Part 5: ย้ายโปรเจกต์จาก JavaScript มา TypeScript (Migration — การย้าย/อพยพ)
ถ้าคุณมีโปรเจกต์ JavaScript เดิมอยู่ คุณไม่ต้องเขียนใหม่ทั้งหมด — ย้ายได้ทีละนิด
5.1 กลยุทธ์ — ค่อยเป็นค่อยไป
อย่าพยายามแปลงทั้งโปรเจกต์ในครั้งเดียว ทำตามลำดับนี้:
- ติดตั้ง TypeScript และสร้าง
tsconfig.jsonโดยเปิดallowJs: true(ให้ไฟล์.jsกับ.tsอยู่ด้วยกันได้) และเริ่มด้วยstrict: falseก่อน - เปลี่ยนชื่อไฟล์ทีละไฟล์ จาก
.jsเป็น.ts— เริ่มจากไฟล์เล็ก ๆ ที่ไม่มีใครพึ่งพา (utility, helper) - แก้ error ที่เกิดขึ้น ในไฟล์นั้นจนหมด
- ค่อย ๆ เพิ่มความเข้มงวด — เมื่อแปลงไฟล์ได้พอสมควรแล้ว ค่อยเปิด
noImplicitAny→ แก้ → เปิดstrictNullChecks→ แก้ → สุดท้ายเปิดstrict: true - เปิด setting แนะนำเพิ่ม (เมื่อโค้ดส่วนใหญ่เป็น TS แล้ว) — ดูหัวข้อ 5.4 ด้านล่าง
5.4 tsconfig settings ที่แนะนำ (สถานะ 2026)
เมื่อย้ายเสร็จแล้ว แนะนำให้เปิด setting เหล่านี้ใน tsconfig.json:
json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}อธิบายแต่ละตัว:
strict: true— เปิด strict mode ทั้งชุด (รวมnoImplicitAny,strictNullChecks,strictFunctionTypesฯลฯ) ไม่ต่อรอง — ค่านี้คือเส้นแบ่งระหว่าง "เขียน TypeScript จริง" กับ "เขียน JavaScript แต่งกลิ่น"noUncheckedIndexedAccess: true— บังคับให้ access array/object ด้วย index คืน typeT | undefined(เพราะ index อาจอยู่นอกขอบ array หรือ key อาจไม่มีจริง) ทำให้ต้องเช็กundefinedก่อนใช้ ปลอดภัยขึ้นมาก:typescriptconst arr = [1, 2, 3]; const first = arr[0]; // ปกติ: number — เปิดแล้ว: number | undefined // first.toFixed(2); // ❌ error — ต้องเช็ก undefined ก่อน if (first !== undefined) first.toFixed(2); // ✅verbatimModuleSyntax: true— บังคับให้import typeที่จะใช้แค่ระดับ type ต้องเขียนimport typeชัด ๆ (ไม่เขียน TS จะลบให้เอง) ช่วยให้ผลของtscตรงกับสิ่งที่เขียน — สำคัญมากเมื่อใช้ ESMtarget: "ES2022"+module/moduleResolution: "NodeNext"— เหมาะกับ Node.js 22-24 + ESM
5.2 ทางลัด — ใช้ JSDoc ก่อน (ยังไม่ต้องเปลี่ยนนามสกุล)
ถ้ายังไม่อยากเปลี่ยนนามสกุลไฟล์ คุณ "ได้ type check" ในไฟล์ .js ได้ด้วยการเขียน comment แบบ JSDoc:
javascript
// ไฟล์: math.js (ยังเป็น .js)
/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
function add(a, b) {
return a + b;
}แล้วเปิดใน tsconfig.json:
json
{ "allowJs": true, "checkJs": true }TypeScript จะ "อ่าน" JSDoc แล้วตรวจ type ให้ — add("5", 3) จะ error แม้ในไฟล์ .js
5.3 พัก error ชั่วคราว — // @ts-expect-error
ระหว่างย้าย คุณอาจเจอ error ที่ "ยังไม่อยากแก้ตอนนี้" ใช้ comment นี้พักไว้ก่อน:
typescript
// @ts-expect-error — โค้ดนี้ยังไม่มี type จะแก้ทีหลัง
const result = oldUntypedFunction();@ts-expect-error ทำให้ TypeScript "ข้าม" error บรรทัดถัดไป — และข้อดีคือ ถ้าวันหนึ่งบรรทัดนั้นไม่มี error แล้ว TypeScript จะเตือนว่า "ลบ comment นี้ได้แล้ว" ทำให้เราไม่ลืมเก็บกวาด
(มี // @ts-ignore ที่คล้ายกัน แต่ @ts-expect-error ดีกว่าเพราะมันเตือนเมื่อไม่จำเป็นแล้ว)
Part 6: เครื่องมือในงานจริง
6.1 Linter — typescript-eslint (ESLint 9 flat config)
Linter (ลินเทอร์ — เครื่องมือที่ตรวจ "คุณภาพโค้ด" นอกเหนือจาก type) เตือนตัวแปรที่ประกาศแล้วไม่ใช้ เตือน pattern ที่เสี่ยง ตั้งแต่ ESLint 9 เป็นต้นไป ค่าตั้งต้นใช้ flat config (eslint.config.js) ไม่ใช่ .eslintrc แบบเดิม
bash
npm install -D eslint typescript-eslintjavascript
// eslint.config.js (ESLint 9 flat config)
import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true, // อ่าน tsconfig อัตโนมัติ (TS 5.7+ แนะนำ)
},
},
}
);typescript-eslint มี rule ที่เข้าใจ type — เช่น เตือนถ้าคุณ await สิ่งที่ไม่ใช่ Promise
💡 Biome — ทางเลือกใหม่ที่กำลังมาแรง — Biome เป็น linter + formatter ตัวเดียวเขียนด้วย Rust เร็วกว่า ESLint + Prettier มาก โปรเจกต์ใหม่หลายเจ้าใช้ Biome คู่กับ Prettier (Biome ทำหน้าที่ lint, Prettier ทำหน้าที่ format) — หรือใช้ Biome ตัวเดียวทั้งคู่ก็ได้ ยังครอบคลุม rule ไม่ครบเท่า ESLint แต่กำลังพัฒนาเร็ว
6.2 การทดสอบ — Vitest (แทน Jest)
Vitest เป็นเครื่องมือทดสอบยอดนิยมที่เข้ากับ TypeScript ดี — สถานะ 2026 Vitest เป็น default ของโปรเจกต์ใหม่ ส่วนใหญ่ (แทนที่ Jest ที่เคยครองตลาด) เพราะ:
- เร็วกว่ามาก (ใช้ Vite/esbuild)
- API ส่วนใหญ่เข้ากันได้กับ Jest — migrate ง่าย
- รองรับ ESM และ TypeScript ตรง ๆ ไม่ต้องตั้ง Babel/ts-jest
typescript
import { describe, it, expect } from "vitest";
import { calculateTotal } from "./cart.js"; // ESM ต้องมี .js extension! (ดู 6.4)
describe("calculateTotal", () => {
it("คำนวณราคารวมถูกต้อง", () => {
expect(calculateTotal(100, 3)).toBe(300);
});
});6.3 แยกหน้าที่ของเครื่องมือ build
จำจากบทที่ 01 — "ตรวจ type" กับ "สร้างไฟล์" เป็นคนละงาน:
tsc --noEmit— ตรวจ type อย่างเดียว ช้าแต่แม่นยำ → ใช้ใน CI (Continuous Integration — ระบบรวมและตรวจโค้ดอัตโนมัติทุกครั้งที่มีคนส่งโค้ดใหม่) ก่อน merge (การ "รวม" สาขาโค้ดของเราเข้ากับโค้ดหลัก เช่น branchmainของทีม) เข้าโปรเจกต์หลัก- Vite / esbuild / Bun — สร้างไฟล์จริง เร็วมาก (ลบ type ทิ้ง ไม่ตรวจ) → ใช้ build
ใน CI ควรมีขั้นตอน tsc --noEmit เสมอ เพื่อ "บล็อก" code ที่มี type error ไม่ให้เข้าโปรเจกต์
6.4 ESM module และเรื่อง .js extension
สถานะ 2026 โปรเจกต์ TypeScript ใหม่ส่วนใหญ่ใช้ ESM (ECMAScript Module — ระบบ module มาตรฐานของ JavaScript) ไม่ใช่ CommonJS แบบเดิม ตั้งใน package.json:
json
{ "type": "module" }กับดักสำคัญ — ใน ESM เมื่อ import ไฟล์ในโปรเจกต์ตัวเอง (relative import) ต้องใส่นามสกุล .js ต่อท้าย แม้ไฟล์จริงจะเป็น .ts:
typescript
// ❌ CommonJS style — error ใน ESM
import { calculateTotal } from "./cart";
// ✅ ESM — ต้องมี .js (แม้ไฟล์จริงคือ cart.ts!)
import { calculateTotal } from "./cart.js";เหตุผล: TypeScript ไม่เขียน .ts → .js ให้ตอน compile (compiler ออกแบบให้เป็นแบบนั้นโดยตั้งใจ) ดังนั้นต้องเขียน .js ในซอร์สเลย — verbatimModuleSyntax ที่เปิดไว้ใน Part 5.4 ก็ช่วยจับเคสนี้
6.5 Barrel file (ไฟล์รวม export กลาง)
Barrel file (อ่าน "บาร์เรล" = "ถัง" — ถังที่รวม export หลายไฟล์ไว้ในที่เดียว) คือไฟล์ index.ts ที่ re-export ของจากหลายไฟล์ในโฟลเดอร์เดียว ทำให้ import จากภายนอกสะดวก:
typescript
// src/utils/index.ts (barrel file)
export { formatDate } from "./date.js";
export { slugify } from "./string.js";
export type { User } from "./user.js";typescript
// ที่อื่น — import ครั้งเดียวได้หลายตัว
import { formatDate, slugify, type User } from "./utils/index.js";⚠️ ข้อเสียที่ควรรู้: barrel file ทำให้ tree-shaking (การลบโค้ดที่ไม่ได้ใช้) ทำงานยากขึ้น — bundle อาจใหญ่กว่าที่ควร และทำให้ vitest/jest โหลดไฟล์เยอะตอนเทสต์ — โปรเจกต์ใหญ่บางทีมเลิกใช้ barrel แล้วให้ import จากไฟล์ตรง ๆ แทน ชั่งน้ำหนัก "ความสะดวก" กับ "ขนาด bundle" ดู
Part 7: ข้อผิดพลาดที่พบบ่อย (และวิธีแก้)
รวมข้อผิดพลาดที่มือใหม่ทำบ่อย — เช็กตัวเองว่าหลีกเลี่ยงได้ไหม
7.1 ใช้ any พร่ำเพรื่อ
ข้อผิดพลาดอันดับหนึ่ง — ใส่ any เพื่อให้ error หาย ซึ่งปิด type checking ทั้งหมด (เสียประโยชน์ของ TypeScript) ถ้าไม่รู้ type จริงให้ใช้ unknown ที่บังคับให้ narrow ก่อนใช้ ปลอดภัยกว่า:
typescript
function process(data: any) { /* ... */ } // ❌ เสีย type safety ทั้งหมด
function process(data: unknown) { /* ... */ } // ✅ ปลอดภัยกว่า ต้อง narrow ก่อนใช้7.2 ใช้ as เป็นทางลัด
as (type assertion) คือการ "บอก TypeScript ให้เชื่อ" ว่าค่าเป็น type นั้นโดยไม่ตรวจจริง — อันตรายกับข้อมูลภายนอก (API) ที่อาจไม่ตรง ควร validate จริงด้วย Zod แทน:
typescript
const user = response as User; // ❌ แค่ "เชื่อ" ไม่ได้ตรวจจริง
const user = UserSchema.parse(response); // ✅ ตรวจจริงด้วย Zod7.3 ใส่ ? (optional) ทั้งที่ไม่ควร optional
typescript
type User = {
name?: string; // ❌ ถ้าในความเป็นจริง user ต้องมีชื่อเสมอ ก็ไม่ควรเป็น optional
};ใส่ ? เฉพาะ field ที่ "ขาดได้จริง ๆ" ตามตรรกะธุรกิจ — การใส่ ? มั่ว ๆ ทำให้ต้องเช็ก undefined ทุกที่โดยไม่จำเป็น
7.4 ปิด strict เพราะ error เยอะ
อย่าทำ — error ที่ strict ฟ้องคือบั๊กจริงที่รอระเบิด การปิด strict ไม่ได้ทำให้บั๊กหายไป แค่ซ่อนมันไว้
Part 8: Checklist สรุป — TypeScript ที่ดี
เมื่อเริ่มโปรเจกต์ใหม่ หรือ review โค้ด ใช้ checklist นี้:
การตั้งค่า
- ☐ เปิด
strict: true+noUncheckedIndexedAccess - ☐ มีขั้นตอน
tsc --noEmitใน CI
การเขียน type
- ☐ เลี่ยง
any— ใช้unknownแทน - ☐ เลี่ยง
as— ใช้ type guard หรือ Zod - ☐ ใช้
?.และ??จัดการ null/undefined - ☐ ใช้ discriminated union สำหรับ state ที่มีหลายแบบ
- ☐ ใช้
as const satisfiesสำหรับ object คงที่ - ☐ ใส่
?เฉพาะ field ที่ optional จริง ๆ - ☐ ใช้ exhaustive check (
never) กับ switch ของ union
ข้อมูลภายนอก
- ☐ ตรวจข้อมูลจาก API / ฟอร์ม / env ด้วย Zod เสมอ
โครงสร้าง
- ☐ ใช้
import typeกับ type - ☐ แชร์ type ระหว่าง frontend/backend (shared package หรือ tRPC)
- ☐ เขียน comment อธิบาย type ที่ซับซ้อน
Part 9: Lab — ลงมือทำ
Lab 1 — Zod ครบวงจร
ฝึกใช้ Zod ครบวงจร — ประกาศ schema, infer type จาก schema, validate ข้อมูลขาเข้า แล้วใช้ type ที่ได้อย่างปลอดภัย เห็นภาพการเชื่อม runtime validation กับ static type เข้าด้วยกัน:
typescript
import { z } from "zod";
// schema สำหรับฟอร์มสมัครสมาชิก
const SignupSchema = z.object({
username: z.string().min(3, "ชื่อผู้ใช้ต้องยาวอย่างน้อย 3 ตัว"),
email: z.string().email("รูปแบบอีเมลไม่ถูกต้อง"),
password: z.string().min(8, "รหัสผ่านต้องยาวอย่างน้อย 8 ตัว"),
age: z.number().min(13, "ต้องอายุ 13 ปีขึ้นไป"),
});
type SignupData = z.infer<typeof SignupSchema>;
function handleSignup(rawInput: unknown): SignupData | null {
const result = SignupSchema.safeParse(rawInput);
if (!result.success) {
console.log("ข้อมูลไม่ถูกต้อง:", result.error.issues[0]?.message);
return null;
}
return result.data;
}
console.log(handleSignup({ username: "al", email: "x", password: "1", age: 10 }));
console.log(handleSignup({ username: "alice", email: "a@x.com", password: "12345678", age: 25 }));Lab 2 — Type-safe form state
💡 Lab นี้ใช้ mapped type จากบทที่ 10 — ถ้าข้ามบท 10 มา ให้กลับไปอ่าน Part 1 ของบทนั้นก่อน หรือดูตัวอย่างเป็น "ของแถม" ก็ได้
typescript
// แปลง type ข้อมูล ให้กลายเป็น type ของ "สถานะฟอร์ม"
type FormState<T> = {
[K in keyof T]: {
value: T[K];
error?: string;
touched: boolean;
};
};
type LoginInput = { email: string; password: string };
type LoginFormState = FormState<LoginInput>;
// {
// email: { value: string; error?: string; touched: boolean };
// password: { value: string; error?: string; touched: boolean };
// }
const initialState: LoginFormState = {
email: { value: "", touched: false },
password: { value: "", touched: false },
};(สังเกตว่าเราใช้ mapped type จากบทที่ 10 มาทำงานจริง)
Lab 3 — วางแผน migration
ถ้าคุณมีโปรเจกต์ JavaScript ลองเขียนแผนย้ายเป็น TypeScript:
- ติดตั้ง
typescript, สร้างtsconfig.json(allowJs: true,strict: false) - เปิด
checkJsแล้วเขียน JSDoc ให้ไฟล์หลัก 3–5 ไฟล์ - เปลี่ยน 1 ไฟล์เป็น
.tsแก้ error จนหมด - ทำซ้ำข้อ 3 ทีละไฟล์
- เปิด
noImplicitAny→ แก้ → เปิดstrict: true
Part 10: Checkpoint
1. ใส่ type ให้ React component props ยังไง?
สร้าง object type ธรรมดา (เช่น type Props = { name: string }) แล้วใช้กับพารามิเตอร์ของฟังก์ชัน component — เหมือนใส่ type ให้พารามิเตอร์ฟังก์ชันปกติ
2. ทำไมการแชร์ type ระหว่าง frontend/backend ถึงมีประโยชน์?
เมื่อทั้งสองฝั่งใช้ type ชุดเดียวกัน — ถ้า backend เปลี่ยนรูปแบบข้อมูล frontend จะ error ทันทีตอน compile ก่อนถึงมือผู้ใช้
3. ทำไมต้องใช้ Zod ทั้งที่มี TypeScript?
เพราะ type ของ TypeScript หายตอน runtime — ข้อมูลจากภายนอก (API, ฟอร์ม) TypeScript ตรวจให้ไม่ได้ Zod ตรวจสอบจริง ๆ ตอน runtime และ infer type ให้ด้วย
4. parse() กับ safeParse() ของ Zod ต่างกันยังไง?
parse() ถ้าข้อมูลผิดจะโยน error safeParse() คืน result object ({ success: true/false, ... }) ให้เราจัดการเองโดยไม่โยน error
5. กลยุทธ์ย้าย JavaScript → TypeScript ที่ดีคืออะไร?
ค่อยเป็นค่อยไป — เปิด allowJs, เริ่ม strict: false, เปลี่ยนไฟล์ทีละไฟล์จากเล็กไปใหญ่, ค่อย ๆ เพิ่มความเข้มงวด (noImplicitAny → strictNullChecks → strict)
6. // @ts-expect-error ดีกว่า // @ts-ignore ตรงไหน?
@ts-expect-error จะเตือนเราเมื่อบรรทัดนั้นไม่มี error แล้ว (บอกว่าลบ comment ได้) ทำให้ไม่ลืมเก็บกวาด ส่วน @ts-ignore ไม่เตือน
7. ทำไมไม่ควรปิด strict แม้ error เยอะ?
error ที่ strict ฟ้องคือบั๊กจริงที่รอจะระเบิดตอน runtime — การปิด strict ไม่ได้ทำให้บั๊กหาย แค่ซ่อนมันไว้
Part 11: สรุปบทนี้ และจบเล่ม
สรุปบทนี้
- React — ใส่ type ให้ props (object type ธรรมดา), hooks (generic), event
- Node.js — ใส่ type ให้ route, env variables
- แชร์ type frontend/backend — shared package หรือ tRPC → จับบั๊ก API ตั้งแต่ compile
- Zod — ตรวจสอบข้อมูลภายนอกตอน runtime — กฎทอง: ตรวจทุกจุดที่ข้อมูลเข้าโปรแกรม
- Migration — ค่อยเป็นค่อยไป ทีละไฟล์ ค่อย ๆ เพิ่มความเข้มงวด
- เครื่องมือ — ESLint 9 (flat config) / Biome เป็น linter, Vitest แทน Jest,
tsc --noEmitใน CI - tsconfig แนะนำ —
strict,noUncheckedIndexedAccess,verbatimModuleSyntax - ESM — ใส่
.jsใน relative import แม้ไฟล์จริงเป็น.ts - เลี่ยง —
any,asมั่ว,?เกินจำเป็น, ปิดstrict
🎉 จบเล่ม TypeScript
ขอแสดงความยินดี! คุณเดินทางครบทั้ง 12 บท (รวมบท 0) — จาก let x: number = 5 ในบทแรก จนถึง template literal type และ branded type ในบทท้าย ๆ
สิ่งที่คุณทำได้ตอนนี้:
- เข้าใจว่า TypeScript ทำงานยังไง และทำไมมันถึงสำคัญ
- เขียน type ให้โค้ดได้อย่าง type-safe และ idiomatic (อิดิโอแมติก—เป็นธรรมชาติตามสไตล์ที่ภาษานั้นนิยมเขียนกัน)
- เข้าใจ generic, utility types, narrowing — เครื่องมือระดับกลาง
- อ่าน type ขั้นสูง (conditional, mapped, infer) ของ library ดัง ๆ ออก
- เอา TypeScript ไปใช้กับงานจริง — React, Node, การตรวจสอบข้อมูล
ก้าวต่อไป
การเรียนรู้ไม่จบแค่นี้ — ขั้นต่อไปที่แนะนำ:
- ฝึกฝน type ที่ Type Challenges หรือ typehero.dev
- อ่านเอกสารทางการ — TypeScript Handbook
- ติดตาม release notes ของ TypeScript ทุกไตรมาส — ภาษามีของใหม่เสมอ
- อ่านโค้ด
.d.tsของ library ดัง ๆ เช่น React, Zod — เรียนรู้จากของจริง - ลงมือทำโปรเจกต์จริง — ความเชี่ยวชาญมาจากการเขียน ไม่ใช่การอ่าน
💬 ข้อคิดส่งท้าย: TypeScript ไม่ได้มีไว้ทำให้การเขียนโค้ดยากขึ้น — มันมีไว้ทำให้คุณ มั่นใจ ในโค้ดที่เขียน ทุก type ที่คุณใส่คือคำสัญญาที่ TypeScript จะคอยช่วยตรวจสอบให้ตลอดอายุของโปรเจกต์ ขอให้สนุกกับการเขียน TypeScript ครับ 🚀