โหมดมืด
บทที่ 13 — TypeScript เชิงลึกสำหรับ React
← บทที่ 12: React Native | สารบัญ | บทที่ 14: React Internals →
🔴 ระดับ: สูง — อ่านทีหลังได้ บทนี้เป็น TypeScript ระดับ senior มือใหม่ไม่ต้องรีบ ใช้ TS พื้นฐาน (บท 0 §4) เขียนแอปได้สบายแล้ว ค่อยกลับมาอ่านบทนี้ตอนเริ่มทำ component ที่ reuse เยอะ ๆ
prerequisite — ต้องรู้ก่อน: type annotation, interface, union type (
A | B), generic เบื้องต้น (Array<T>) — ถ้ายังไม่แน่ใจให้ทบทวน บท 0 §4 ก่อน
ใน 12 บทแรก เราใช้ TypeScript แบบ "พื้นฐาน" — มี type, มี interface
บทนี้ขยับขึ้นอีกขั้น: เขียน TS ที่ทีมใหญ่ใช้ในระดับ senior (อาวุโส — dev ที่มีประสบการณ์ทำ project ใหญ่)
💬 ถึงจะเรียกว่า senior แต่บทนี้เริ่มจาก review พื้นฐานก่อน แล้วค่อย ๆ ขยับขึ้น — อ่านไปได้เรื่อย ๆ ไม่ต้องรู้ทุกอย่างในทีเดียว
📖 คำศัพท์รวมของบท (อ่านก่อน — บทใช้คำเหล่านี้บ่อย):
- generic = type ที่รับ type มาเป็น parameter (
Array<T>,Promise<T>) — ทำให้ function/type ใช้ได้กับหลายชนิด- union = type ที่ "อย่างใดอย่างหนึ่ง" (
string | number)- discriminated union = union ที่ทุก member มี field พิเศษบอกชนิด (เช่น
type: 'icon') → TS แยกออกว่าตอนนี้เป็น case ไหน- narrow / narrowing = TS "บีบ" type ให้แคบลง (เช่น ใน
if (typeof x === 'string')ภายใน if x จะเป็น string)- exhaustive check = ตรวจให้ครบทุกกรณีของ union — ใส่ case ขาด → TS เตือน
- polymorphic component = component ที่เปลี่ยน HTML tag ได้ (
<Box as="a">,<Box as="h1">)- infer = ใน conditional type, "ดูดเอา type ออกมา" (เช่น
T extends Array<infer U> ? U : never)- conditional type = type แบบ if/else (
T extends U ? X : Y)- mapped type = สร้าง type ใหม่จาก type เดิมโดยวนทุก key (
{ [K in keyof T]: ... })- branded type / nominal typing = ใส่ "ตรา" ลงไปทำให้ type ที่ structure เหมือนกันต่างกันได้ (เช่น
UserIdvsOrderIdที่ทั้งคู่เป็น string แต่ใช้แทนกันไม่ได้)- satisfies (TS 4.9+) = "ตรวจว่าตรงกับ type นี้ แต่อย่า widen ออก" — keep literal type ไว้
- widen = TS ขยาย type ให้กว้างขึ้น เช่น
"dark"กลายเป็นstringทั่วไป ทำให้เสีย literal type ที่แน่นอน- utility type = type สำเร็จที่ TS ให้มา (
Partial<T>,Pick<T, K>,Omit<T, K>,Record<K, V>)React.FC= type สำเร็จเก่าของ React — ปี 2026 ไม่แนะนำให้ใช้ (ดู §2.1)
จบบทนี้คุณจะ:
- เขียน reusable component ที่ type-safe เต็มที่
- เข้าใจ
asconst,satisfies,infer, conditional type, mapped type - ใช้ utility type ของ React (
ComponentProps,Ref,PropsWithChildren) อย่างถูกต้อง - สร้าง type-safe API hook (TanStack Query + Zod)
- Debug TypeScript error ที่ซับซ้อนได้
📚 ต้องรู้ก่อน (prerequisite): ถ้ายังไม่แน่ใจ TypeScript พื้นฐาน → ทบทวน บท 0 §4 (TypeScript พื้นฐาน) ก่อน หรืออ่าน TypeScript handbook ทางการที่ https://www.typescriptlang.org/docs/handbook/
ใช้เวลา 4-5 ชั่วโมง
Part 1: ทบทวน TypeScript ที่ต้องรู้ก่อน
1.1 Type vs Interface
typescript
type User = { id: number; name: string };
interface User { id: number; name: string }ใช้เกือบเหมือนกัน แต่:
| type | interface | |
|---|---|---|
| Union | type X = A | B | ❌ |
| Intersection | type X = A & B | interface X extends A, B |
| Declaration merging | ❌ | ✅ (ขยายได้หลายที่) |
| Computed/Indexed | type Age = Person['age'] (ดึง type จาก key ของ object) | ❌ |
Mapped key ([K in ...]) | ✅ ({ [K in keyof T]: T[K] }) | ❌ (interface ไม่รองรับ mapped type) |
extends | ผ่าน intersection | ตรง ๆ |
💡 Declaration merging — interface สามารถประกาศซ้ำชื่อเดิมได้ และ TypeScript จะรวมเป็น type เดียว:
typescriptinterface User { name: string } interface User { age: number } // User = { name: string; age: number }type ทำแบบนี้ไม่ได้ (จะ error "Duplicate identifier")
Rule of thumb:
- Component props →
type(มาตรฐานในวงการ React) - Library API ที่อาจมี extend →
interface
1.2 Literal Types + Union
literal type + union คือเครื่องมือที่ใช้บ่อยสุดใน React TypeScript — แทนที่จะใช้ string กว้าง ๆ ให้ระบุค่าที่เป็นไปได้จริง (เช่น "loading" | "success" | "error") ทำให้ type system ตรวจได้ว่าใส่ค่าผิดและ autocomplete ช่วยได้ เป็น "string enum" ที่เบากว่า enum จริง:
typescript
type Status = "loading" | "success" | "error";
type Variant = "primary" | "secondary" | "danger";→ ทำ "string enum" ที่ type system รู้ค่าจริง
1.3 as const — Literal Inference
typescript
// แบบที่ 1: ไม่ใส่ as const
const colors = ["red", "green", "blue"];
// type: string[]
// แบบที่ 2: ใส่ as const → ได้ literal type
const colorsConst = ["red", "green", "blue"] as const;
// type: readonly ["red", "green", "blue"]
type Color = typeof colorsConst[number]; // "red" | "green" | "blue"ใช้เก็บ literal value แล้วดึงเป็น type — ดีกว่า hardcode 2 ที่
1.4 satisfies (TS 4.9+)
typescript
// สมมุติ Config ประกาศ theme เป็น string (ไม่ใช่ literal union)
type Config = { theme: string; fontSize: number };
// ❌ as บังคับ type → ถ้าผิดไม่บอก
const config = {
theme: "dark",
fontSize: 14,
} as Config;
// ❌ annotation → เมื่อ Config กำหนด theme: string ค่า literal จะหาย
const config2: Config = {
theme: "dark",
fontSize: 14,
};
// IDE จะเห็น config2.theme เป็น type: string ไม่ใช่ literal "dark"
// ✅ satisfies → check ว่าตรงกับ Config พร้อมกับเก็บ literal type ไว้
const config3 = {
theme: "dark",
fontSize: 14,
} satisfies Config;
// IDE จะเห็น config3.theme → type: "dark" (literal ยังอยู่!)→ ใช้บ่อยมากใน config object
Part 2: Component Props ระดับมืออาชีพ
2.1 Basic — ทุกคนทำได้
เริ่มจากพื้นฐานสุดของการ type props — ประกาศ type ของ object ที่ component รับ (ใช้ ? สำหรับ optional) แล้ว destructure ใน parameter เท่านี้ก็ได้ autocomplete + type check ของ props ครบ:
typescript
type ButtonProps = {
label: string;
onClick: () => void;
disabled?: boolean;
};
function Button({ label, onClick, disabled }: ButtonProps) {
return <button onClick={onClick} disabled={disabled}>{label}</button>;
}⚠️ อย่าใช้
React.FCสำหรับ function component ใหม่ในปี 2026 — ถือเป็น legacy (แบบเก่าที่ยังใช้ได้ แต่มีวิธีใหม่ที่ดีกว่า) style แล้ว เคยฮิตสมัย TypeScript เก่า แต่ปัจจุบัน community ไม่แนะนำเพราะ:
- ใน
@types/reactเวอร์ชันเก่า (ก่อน v18)React.FCแอบใส่children: ReactNodeให้ทุก component โดยอัตโนมัติ ทั้งที่บาง component ไม่ได้ตั้งใจรับ children ตั้งแต่@types/reactv18 เป็นต้นมา พฤติกรรมนี้ถูกแก้ไขแล้ว แต่คนที่เคยเจอ behavior เก่ายังสับสนกับ component ที่เขียนไว้แบบเก่าอยู่- Generic component ใช้ยาก —
React.FCทำ type inference ของ generic ได้ด้อยกว่า function declaration ตรง ๆ มาก- Flexibility น้อยกว่า: overload, default export, และ pattern บางอย่างทำได้ยากกว่า
- Default props (
Component.defaultProps) deprecated ใน React 19 อยู่แล้ววิธีที่แนะนำ: ประกาศ type ของ props แล้วใช้ใน parameter ตรง ๆ (
function Button({ ... }: ButtonProps)) — ชัดและ flex กว่า:typescript// ❌ legacy — ไม่แนะนำสำหรับ component ใหม่ const Button: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>; // ✅ แนะนำ — explicit, generic-friendly, ไม่ implicit children function Button({ label }: ButtonProps) { return <button>{label}</button>; }📚 คุณยังเจอ
React.FCใน source code ของ library เก่า ๆ (รวมถึงบาง part ของ shadcn/ui หรือ Radix) — ไม่ใช่ bug, แค่ legacy style ที่ทีมเขายังไม่ refactor
💡 คำว่า "legacy" ในบทนี้ = แบบเก่าที่ยังใช้ได้ ไม่ใช่ผิด แต่ปี 2026 มีวิธีใหม่ที่ดีกว่า — ถ้าเริ่ม project ใหม่อย่าเอาแบบ legacy ไปใช้
2.2 Extending Native Props
ปัญหา: User อยากใส่ aria-label, data-testid, className, style ฯลฯ
typescript
// ❌ list ทุก prop เอง → ทำไม่หมด
type ButtonProps = {
label: string;
className?: string;
// ... 50 props ...
};
// ✅ extend จาก HTML element
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
label: string;
};
function Button({ label, ...rest }: ButtonProps) {
return <button {...rest}>{label}</button>;
}React.ComponentPropsWithoutRef<"button"> = ทุก HTML attr ของ button (รวม event, aria, data, ฯลฯ)
React.ComponentPropsWithRef<"button"> = + ref (ref = reference ตรงไปยัง DOM element — ใช้เมื่อต้องการควบคุม element โดยตรง เช่น focus หรือ scroll)
💡 ใช้
WithoutRefเป็นค่าเริ่มต้น — เปลี่ยนเป็นWithRefเฉพาะเมื่อ component นั้นตั้งใจรับ ref และส่งต่อไปยัง DOM element
2.3 Discriminated Union — props ที่ต้องเลือกแบบ
บางครั้ง props มีหลายแบบที่ใช้ร่วมกันไม่ได้ (เช่น ปุ่มแบบ icon ต้องมี icon ห้ามมี label และกลับกัน) — discriminated union แยกตาม "ตัวบ่งชี้" (variant) ทำให้ TS รู้ว่าแต่ละกรณีมี field อะไรและบังคับให้ใส่ครบ/ถูก ส่วน never ห้ามไม่ให้ใส่ field ของอีกแบบ (never = type ที่บอกว่า "field นี้ห้ามมีอยู่เลย — ถ้าใส่ค่าใดก็ตาม TS จะ error"):
typescript
type Props =
| { variant: "icon"; icon: ReactNode; label?: never }
| { variant: "text"; label: string; icon?: never };
function Button(props: Props) {
if (props.variant === "icon") {
return <button>{props.icon}</button>; // TS รู้ว่ามี icon
}
return <button>{props.label}</button>; // TS รู้ว่ามี label
}
// usage
<Button variant="icon" icon={<Star />} />
<Button variant="text" label="Click" />
<Button variant="icon" label="..." /> // ❌ TS error: icon variant ไม่มี labelnever = "ห้ามมี" → เป็น exclusive
2.4 PropsWithChildren
component ที่รับ children ใส่ children?: React.ReactNode เองก็ได้ แต่ React มี helper PropsWithChildren<T> ที่เติม children ให้อัตโนมัติ — สั้นกว่าและสื่อเจตนาชัดว่า component นี้ตั้งใจรับ children:
typescript
type CardProps = React.PropsWithChildren<{
title: string;
}>;
// เท่ากับ:
type CardProps = {
title: string;
children?: React.ReactNode;
};2.5 children เป็น function
children ไม่จำเป็นต้องเป็น JSX เสมอ — เป็น "function" ก็ได้ (render prop pattern) เพื่อส่ง data ภายใน component ออกไปให้ผู้ใช้ render เอง การ type children เป็นฟังก์ชัน (value: T) => ReactNode ทำให้ generic + type-safe:
typescript
type RenderProp<T> = {
children: (value: T) => React.ReactNode;
};
function FetchData<T>({ url, children }: { url: string } & RenderProp<T>) {
const data = useFetch<T>(url); // useFetch เป็น hook สมมุติเพื่อตัวอย่าง — ไม่ต้องสนใจ implementation ตอนนี้ (ดูของจริงใน Part 9.3)
return <>{children(data)}</>;
}
// usage
<FetchData<User[]> url="/users">
{(users) => <UserList users={users} />}
</FetchData>Part 3: Generic Components
3.1 ปัญหา: List ที่ใช้กับ T ใดก็ได้
component บางตัว (List, Table, Select) ควรทำงานกับข้อมูล type ใดก็ได้แต่ยังคง type safety — ใช้ generic <T> แล้ว TS จะ infer type ให้
หมายเหตุ: คำว่า "infer" ในหัวข้อนี้หมายถึง type inference ทั่วไป (TS เดา type ให้อัตโนมัติ) ไม่ใช่
inferkeyword ใน conditional type ที่จะเรียนใน Part 14
TS จะ infer type จาก props ที่ส่งเข้ามา (เช่น ส่ง users เข้าไป renderItem จะรู้ว่า item เป็น User) ได้ทั้งยืดหยุ่นและปลอดภัย:
typescript
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
};
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// usage
<List
items={users}
renderItem={(u) => u.name} // ← TS infer u: User
keyExtractor={(u) => u.id.toString()}
/>3.2 Generic + ref (React 19 — ไม่ต้อง forwardRef แล้ว)
⚠️ pattern นี้ใช้ได้เฉพาะ React 19+ เท่านั้น — ถ้า project ใช้ React 18 ต้องใช้
forwardRefแทน (ดู Legacy section ข้างล่าง)
💡
forwardRefคือ wrapper function ที่ React < 19 ใช้เพื่อส่งrefผ่าน component ปกติ (เพราะ ref ไม่ใช่ prop ธรรมดา) — ใน React 19 แก้ไขแล้ว ref กลายเป็น prop ธรรมดา ทำให้ไม่ต้องใช้forwardRefอีก
ใน React 19 — ref เป็น prop ปกติ → generic component ทำได้ตรง ๆ ไม่ต้อง forwardRef:
⚠️ ref-as-prop ใช้ได้กับ function component เท่านั้น — class component ยังใช้ ref-to-instance model แบบเดิม (ผูก ref เข้ากับ instance ของ class โดยตรง ไม่ใช่ prop)
typescript
import type { Ref, ReactNode } from 'react';
type ListProps<T> = {
items: T[];
renderItem: (item: T) => ReactNode;
keyExtractor: (item: T) => string;
ref?: Ref<HTMLUListElement>;
};
function List<T>({ items, renderItem, keyExtractor, ref }: ListProps<T>) {
return (
<ul ref={ref}>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}→ pattern นี้คือ THE pattern สำหรับ generic + ref ในปี 2026 — ใช้แบบนี้กับ project ใหม่ทั้งหมด
📦 Legacy: forwardRef workaround (สำหรับ React 18 codebase เท่านั้น)
ใน React < 19 forwardRef ทำให้ generic หาย ต้อง cast trick ช่วย — โค้ดนี้ไว้สำหรับ migrate ของเก่า ไม่ใช่ pattern ที่ควรเขียนใหม่ใน 2026:
typescript
import { forwardRef, type Ref } from 'react';
// ❌ broken — T หายไป: TS infer forwardRef เป็น ForwardRefExoticComponent<ListProps<unknown>>
// (T กลายเป็น unknown เพราะ forwardRef ไม่ preserve generic ของ inner function)
// error ที่จะเห็น: Argument of type 'User[]' is not assignable to parameter of type 'unknown[]'
const ListBroken = forwardRef(<T,>(props: ListProps<T>, ref: Ref<HTMLUListElement>) => {
// <T,> trailing comma ต้องมีเฉพาะใน .tsx ไฟล์ เพื่อให้ TypeScript รู้ว่า <T> เป็น generic ไม่ใช่ JSX tag
// (ใน .ts ไฟล์ธรรมดาไม่ต้องใส่ comma เพราะไม่มี JSX ให้สับสน) — ไม่ต้องจำ syntax นี้ถ้าไม่ maintain React 18 code
return <ul ref={ref}>...</ul>;
});
// ✅ trick — cast wrapper ของ forwardRef ให้กลับมาเป็น generic function type
function ListInner<T>(
props: ListProps<T>,
ref: Ref<HTMLUListElement>
) {
return <ul ref={ref}>...</ul>;
}
const List = forwardRef(ListInner) as <T>(
props: ListProps<T> & { ref?: Ref<HTMLUListElement> }
) => React.ReactElement;3.3 Generic Constraint
generic แบบเปิด (<T>) รับอะไรก็ได้ แต่บางครั้งเราต้องการให้ T มี property บางอย่างแน่ ๆ (เช่นมี id เพื่อใช้เป็น key) — ใช้ extends เป็น constraint บังคับให้ T ต้องมี shape นั้น แล้ว TS จะยอมให้เข้าถึง property นั้นได้อย่างปลอดภัย:
typescript
type WithId = { id: string | number };
type ListProps<T extends WithId> = {
items: T[];
renderItem: (item: T) => ReactNode;
};
function List<T extends WithId>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li> // ← TS รู้ว่ามี id
))}
</ul>
);
}Part 4: Polymorphic Component
4.1 ปัญหา — ทำไมต้อง polymorphic
Library ที่ดี (Radix, shadcn, Chakra) มี <Button as="a" href="..." /> — ใช้ตัวเดียวกัน เปลี่ยน underlying element ได้:
typescript
<Button as="button">Click</Button> // ออกมาเป็น <button>
<Button as="a" href="/x">Link</Button> // ออกมาเป็น <a href="/x">
<Button as={CustomLink} to="/x">Click</Button> // ออกมาเป็น <CustomLink to="/x">ทำไมไม่ทำ 3 component แยก (Button, LinkButton, RouterButton)?
- Style เหมือนกันหมด — duplicate 3 ที่
- เพิ่ม variant ใหม่ → ต้องแก้ 3 ที่
- ผู้ใช้ต้อง remember 3 ชื่อ
Polymorphic component = 1 component, รับ as prop → render element ตามที่บอก + type-safe attribute ของ element นั้น
4.2 ค่อย ๆ build up — step by step
ผมจะไม่กระโดดไปที่ final implementation (โค้ดสำเร็จ) — มาดูทีละขั้นว่าทำไมแต่ละ type ถึงต้องมี
Step 1: รับ as prop ก่อน (ไม่ type-safe — แสดงเพื่อดูปัญหาก่อน จะค่อย ๆ แก้ทีละขั้น)
typescript
type ButtonProps = {
as?: 'button' | 'a' | 'div';
variant?: 'primary' | 'secondary';
children: ReactNode;
};
function Button({ as, variant = 'primary', children, ...rest }: ButtonProps) {
const Component = as || 'button';
return <Component className={`btn-${variant}`} {...rest}>{children}</Component>;
}ปัญหา:
typescript
<Button as="a">Link</Button> // ❌ TS ไม่รู้ว่าควรมี href
<Button as="a" href="/x">Link</Button> // ❌ TS error: ButtonProps ไม่มี href
<Button as="button" href="/x">x</Button> // ❌ ควร error แต่ไม่ error (เพราะ href ไม่มีใน props)Step 2: ใช้ generic C extends ElementType แทน literal union
React.ElementType = type ของทุก element ที่ React render ได้ — string ('div', 'a', ...) หรือ component (MyButton) — ใช้ผ่าน React.ElementType (ต้อง import React from 'react') หรือ import type { ElementType } from 'react' แล้วใช้ตรง ๆ
typescript
import type { ElementType, ReactNode } from 'react'; // ← import ก่อน
type ButtonProps<C extends React.ElementType> = {
as?: C;
variant?: 'primary' | 'secondary';
children: ReactNode;
};
function Button<C extends React.ElementType = 'button'>({
// ↑ C = 'button' คือ default type parameter (= ค่า default ของ generic เมื่อไม่ระบุ — ถ้าไม่ใส่ `as` จะถือว่าเป็น button อัตโนมัติ)
as,
variant = 'primary',
children,
}: ButtonProps<C>) {
const Component = as || 'button';
return <Component className={`btn-${variant}`}>{children}</Component>;
}ก้าวไปข้างหน้า: as ตอนนี้รับอะไรก็ได้ — แต่ยังไม่รู้ attribute ของ element นั้น
Step 3: ดึง prop ของ element ที่ as ชี้ไป
React.ComponentPropsWithoutRef<C> = "props ทั้งหมดของ component หรือ element type C" (ไม่รวม ref)
typescript
// ลองดู:
type AnchorProps = React.ComponentPropsWithoutRef<'a'>;
// = { href?: string; target?: string; rel?: string; ... + onClick, className, etc. }
type ButtonProps = React.ComponentPropsWithoutRef<'button'>;
// = { type?: 'button' | 'submit' | 'reset'; disabled?: boolean; ... }→ ใช้ผสมกับ ButtonProps ของเรา:
typescript
type ButtonProps<C extends React.ElementType> = {
as?: C;
variant?: 'primary' | 'secondary';
children: ReactNode;
} & React.ComponentPropsWithoutRef<C>;
// ⭐ รวม props ของ C เข้ามาตอนนี้:
typescript
<Button as="a" href="/x">Link</Button> // ✅ href ได้เพราะมาจาก <a>
<Button as="button" disabled>Click</Button> // ✅ disabled ได้เพราะมาจาก <button>
<Button as="a" disabled>x</Button> // ❌ TS error: <a> ไม่มี disabledStep 4: แก้ name collision — ถ้า prop ของเรา clash กับ element
ปัญหา: <Button as="a" variant="primary"> — แต่ ถ้า <a> มี attribute ชื่อ variant อยู่แล้ว → ชนกัน
แก้ด้วย Omit — ตัด key ของเราออกจาก element props ก่อน merge:
typescript
type AsProp<C extends React.ElementType> = { as?: C };
type OwnProps = {
variant?: 'primary' | 'secondary';
children: ReactNode;
};
type ButtonProps<C extends React.ElementType> =
AsProp<C>
& OwnProps
& Omit<React.ComponentPropsWithoutRef<C>, keyof (AsProp<C> & OwnProps)>;
// ⭐ ตัด 'as', 'variant', 'children' ออกจาก element props ก่อน mergeStep 5: Final — combine + default type parameter
typescript
type AsProp<C extends React.ElementType> = { as?: C };
type PolymorphicProps<C extends React.ElementType, P> =
P
& AsProp<C>
& Omit<React.ComponentPropsWithoutRef<C>, keyof (AsProp<C> & P)>;
// reusable — สำหรับ component อื่นๆ ก็ใช้ PolymorphicProps ได้
type ButtonProps<C extends React.ElementType> = PolymorphicProps<C, {
variant?: 'primary' | 'secondary';
}>;
function Button<C extends React.ElementType = 'button'>({
as,
variant = 'primary',
...rest
}: ButtonProps<C>) {
const Component = as || 'button';
return <Component className={`btn-${variant}`} {...rest} />;
}ใช้:
typescript
<Button>Click</Button> // ✅ default = button
<Button as="a" href="/x">Link</Button> // ✅ href OK
<Button as="a" bad="prop">x</Button> // ❌ TS error: 'bad' is not valid for <a>
<Button as={Link} to="/x">Router Link</Button> // ✅ to OK (เพราะ Link component มี to)
<Button variant="primary" as="a" href="/x">Mix</Button> // ✅ ผสม own + element props📋 ตัวอย่างที่ run ได้ครบ — copy ไปลองใน TypeScript Playground หรือ Vite React project ได้เลย:
typescriptimport React, { type ReactNode, type ElementType } from 'react'; type AsProp<C extends ElementType> = { as?: C }; type PolymorphicProps<C extends ElementType, P> = P & AsProp<C> & Omit<React.ComponentPropsWithoutRef<C>, keyof (AsProp<C> & P)>; type ButtonOwnProps = { variant?: 'primary' | 'secondary'; children?: ReactNode }; type ButtonProps<C extends ElementType> = PolymorphicProps<C, ButtonOwnProps>; function Button<C extends ElementType = 'button'>({ as, variant = 'primary', ...rest }: ButtonProps<C>) { const Component = (as || 'button') as ElementType; return <Component className={`btn-${variant}`} {...rest} />; } // ทดสอบ export default function App() { return ( <div> <Button>Click me</Button> <Button as="a" href="https://example.com">Link</Button> <Button variant="secondary" as="div">Div</Button> </div> ); }
4.3 หลักจำ Polymorphic component
ใน mental model:
อ่าน diagram ไม่ออก? —
PolymorphicPropsรวม 3 ส่วน: (1) prop ของ component เรา (variant, children) (2) propasสำหรับเลือก element type (3) prop ของ element นั้น ๆ (href, disabled, ...) โดยตัดส่วนที่ชนกับ (1) ออกก่อนเพื่อไม่ให้ clash
→ ถ้าจะทำ polymorphic component ของตัวเอง — copy pattern นี้, เปลี่ยน OwnProps ของตัว
4.4 Real-world: Radix Slot + asChild
Radix ใช้ pattern ต่างเล็กน้อย: แทน as="a" ใช้ asChild prop:
tsx
import { Slot } from '@radix-ui/react-slot';
import type { ButtonHTMLAttributes } from 'react'; // ← import จาก react
function Button({ asChild, ...props }: { asChild?: boolean } & ButtonHTMLAttributes<HTMLButtonElement>) {
const Comp = asChild ? Slot : 'button';
return <Comp {...props} />;
}
// usage
<Button>Normal button</Button>
<Button asChild>
<Link href="/x">Render เป็น Link แทน — เอา style + behavior ของ Button ใส่ Link</Link>
</Button><Slot> = "render child โดยส่ง props ที่ฉันได้ลงไปให้ child"
→ shadcn/ui ใช้ pattern นี้ — ดูใน components/ui/button.tsx
4.5 Polymorphic + Ref (full pattern — รูปแบบสมบูรณ์ที่ library ใช้จริง)
🔴🔴 ส่วนนี้หนักมาก — ข้ามได้ เป็น advanced pattern ที่ library ใช้ภายใน 99% ของ application code ไม่ต้องเขียนเอง ถ้าจะใช้จริงให้ copy pattern จาก Radix หรือ shadcn ที่ทดสอบมาแล้ว — code ข้างล่างไว้สำหรับเข้าใจว่ามันทำงานยังไง ไม่ใช่จดจำ
💡 ในส่วนนี้ import ตรงจาก
reactแทนReact.xxxเพื่อความสั้น เช่นimport type { ComponentPropsWithRef } from 'react'แทนReact.ComponentPropsWithRef— ผลเหมือนกันทุกประการ
⭐ Pattern หลัก (React 19+) — ref-as-prop + ComponentPropsWithRef
ใน React 19 — ref เป็น prop ปกติ → polymorphic + ref เขียนสั้นกว่าเดิมมาก ไม่ต้อง forwardRef type-cast trick:
typescript
import type { ComponentPropsWithRef, ElementType, ReactNode } from 'react';
type AsProp<C extends ElementType> = { as?: C };
type PolymorphicProps<C extends ElementType, Props = {}> = Props &
AsProp<C> &
Omit<ComponentPropsWithRef<C>, keyof (AsProp<C> & Props)>;
// ⭐ ComponentPropsWithRef รวม ref type ที่ถูกต้องตาม C อยู่แล้ว
type TextOwnProps = { variant?: 'body' | 'heading'; children?: ReactNode };
function Text<C extends ElementType = 'p'>({
as,
variant = 'body',
ref,
...rest
}: PolymorphicProps<C, TextOwnProps>) {
const Comp = (as ?? 'p') as ElementType;
return <Comp ref={ref} data-variant={variant} {...rest} />;
}
// usage — ref type ถูกต้องตาม `as` อัตโนมัติ
<Text ref={(el: HTMLParagraphElement | null) => {}}>Body</Text>
<Text as="h1" ref={(el: HTMLHeadingElement | null) => {}}>Heading</Text>
<Text as="a" href="/" ref={(el: HTMLAnchorElement | null) => {}}>Link</Text>→ React 19 ทำให้ pattern นี้สั้นและตรง ไม่ต้องสร้าง PolymorphicComponent type ที่เป็น function signature แยก
⚠️ pattern นี้พึ่งการ infer type
Cจาก TS compiler ซึ่งบางครั้ง editor/TS version อาจ infer ไม่ตรงตามคาด — ถ้าเจอ error แปลก ๆ ลองใส่ type argument ตรง ๆ (เช่น<Text<'a'> as="a" ...>) เพื่อบังคับCให้ชัดเจน
📦 Legacy: Polymorphic + forwardRef (สำหรับ React 18 codebase เท่านั้น)
ก่อน React 19 ต้องใช้ forwardRef ซึ่งทำลาย generic — ต้องสร้าง PolymorphicComponent type ที่เป็น function signature (รับ generic แต่ละครั้งที่เรียก) แล้ว cast manual:
typescript
import { ComponentPropsWithRef, ElementType, ReactNode } from 'react';
type AsProp<C extends ElementType> = { as?: C };
type PolymorphicRef<C extends ElementType> =
ComponentPropsWithRef<C>['ref'];
type PolymorphicComponentPropWithRef<
C extends ElementType,
Props = {}
> = Props &
AsProp<C> &
Omit<ComponentPropsWithRef<C>, keyof (AsProp<C> & Props)>;
type PolymorphicComponent<C extends ElementType, Props = {}> = <
AsType extends ElementType = C
>(
props: PolymorphicComponentPropWithRef<AsType, Props> & {
ref?: PolymorphicRef<AsType>;
}
) => ReactNode;
type TextOwnProps = { variant?: 'body' | 'heading'; children?: ReactNode };
const Text: PolymorphicComponent<'p', TextOwnProps> = ({
as,
variant = 'body',
ref,
...rest
}) => {
const Comp = as ?? 'p';
return <Comp ref={ref} data-variant={variant} {...rest} />;
};ถ้า project ใหม่ — ใช้แบบ React 19 ข้างบนแทน
4.6 Branded Types — กัน ID ปนกัน
ปัญหา: string ทั้ง userId กับ orderId ใช้แทนกันได้โดย TS ไม่เตือน → bug รั่ว
typescript
function getUser(id: string) { ... }
function getOrder(id: string) { ... }
const orderId = '123';
getUser(orderId); // ❌ TS ไม่ตรวจ — pass แต่ลอจิกผิดBranded type = ใส่ "tag" ลงไปทำให้ type ต่างกันที่ระดับ compile time (ตอน build ก่อนรัน) แต่ runtime (ตอน app ทำงานจริง) ยังเป็น string ปกติ — TS แยก type ได้ตอน compile แต่ค่าจริงไม่มี cost เพิ่ม:
typescript
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// helper สร้าง (validate ที่ runtime ด้วยถ้าต้องการ)
function userId(s: string): UserId {
if (!s.startsWith('u_')) throw new Error('invalid user id');
// startsWith('u_') = ตรวจว่า string ขึ้นต้นด้วย "u_"
return s as UserId;
}
function getUser(id: UserId) { ... }
function getOrder(id: OrderId) { ... }
const uid = userId('u_123');
const oid = 'o_456' as OrderId;
getUser(uid); // ✅
getUser(oid); // ❌ Type 'OrderId' is not assignable to 'UserId'
getUser('u_123'); // ❌ Type 'string' is not assignable to 'UserId' → ดี!ใช้ตอนไหน:
- ID หลายชนิดที่ทุกตัวเป็น string (UserId, OrderId, ProductId)
- หน่วยที่ผสมกันไม่ได้ (
Meters,Feet,Celsius,Fahrenheit) - ค่าที่ผ่าน validation/sanitization แล้ว (
SanitizedHTML,ValidatedEmail)
Part 5: Type-Safe Event Handler
5.1 Event Types
event handler ของ React มี type เฉพาะที่ต้องจำ — FormEvent, ChangeEvent<HTMLInputElement>, MouseEvent, KeyboardEvent (พร้อมระบุชนิด element) เมื่อ type ถูก จะได้ autocomplete ของ event (เช่น e.key, e.target.value) และกันใช้ผิด property:
typescript
function MyForm() {
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
// ...
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => { };
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { /* ... */ }
};
return <form onSubmit={onSubmit}>...</form>;
}5.2 currentTarget vs target
typescript
e.currentTarget // element ที่ handler ถูก attach (typed)
e.target // element ที่ trigger จริง (อาจเป็น child — typed as EventTarget)💡 ตัวอย่าง: ถ้าปุ่มมี icon ข้างใน
<button onClick={handler}><Icon /></button>แล้วคนกดที่ icon —e.targetจะเป็น Icon element (child) แต่e.currentTargetจะเป็น button ที่ผูก handler ไว้ → ใช้e.currentTargetเมื่อต้องการ element ที่ฟังเหตุการณ์อยู่จริง
→ ใช้ currentTarget เกือบทุกครั้ง
Part 6: useState — ที่ลึกกว่าที่คิด
6.1 Initial value with type
เมื่อ initial value ไม่บอก type ที่ต้องการชัด (เช่น null แต่จริง ๆ จะเก็บ User) ต้องระบุ type ให้ useState เอง — useState<User | null>(null) บอกว่า state นี้เป็น User หรือ null ทำให้ set ค่าได้ทั้งสองแบบและ TS ตรวจถูก:
typescript
const [user, setUser] = useState<User | null>(null);
setUser({ id: 1, name: "Alice" });
setUser(null);6.2 ❌ Pitfall: useState({})
กับดักที่เจอบ่อย: useState({}) ทำให้ TS infer type เป็น {} (object ว่างเป๊ะ) แล้วพอ setData ด้วย object ที่มี field จะ error — ต้องระบุ type ที่คาดหวังให้ชัด (useState<{ name?: string }>({})):
typescript
const [data, setData] = useState({});
setData({ name: "Alice" }); // ❌ TS infer {} → ไม่ยอม nameแก้:
typescript
const [data, setData] = useState<{ name?: string }>({});6.3 Lazy initial state
ถ้า initial state มาจากการคำนวณที่แพง อย่าใส่ค่าตรง ๆ (จะ run ทุก render) — ส่ง "ฟังก์ชัน" ให้ useState แทน แล้ว React จะเรียกครั้งเดียวตอน mount (mount = ตอน component ถูกสร้างขึ้นครั้งแรกและวางลงบนหน้าจอ) เท่านั้น (lazy initialization):
typescript
const [tree, setTree] = useState(() => buildExpensiveTree());
// ไม่ใช่ useState(buildExpensiveTree()) — จะ run ทุก render6.4 Functional setState
เมื่อ state ใหม่ขึ้นกับค่าเก่า ให้ใช้ functional update (setCount(prev => prev + 1)) แทนการอ้าง state ตรง ๆ — ป้องกัน stale closure (closure ที่ค้าง — function อ่านค่า state เก่าที่ "จำ" ตอนสร้าง ทำให้ใน async/callback ได้ค่าที่ไม่ใช่ล่าสุด) และ TS รู้ type ของ prev ให้อัตโนมัติ:
typescript
// ❌ stale closure bug: setTimeout "จำ" count ตอนสร้าง → ได้ค่าเก่าเสมอ
function BadCounter() {
const [count, setCount] = useState(0);
const increment = () => {
setTimeout(() => {
setCount(count + 1); // count นี้คือค่าตอน render นั้น ไม่ใช่ค่าล่าสุด
}, 1000);
};
}
// ✅ functional update: ใช้ค่าล่าสุดเสมอ — ปลอดภัยจาก stale closure
setCount((prev) => prev + 1);
// prev typed = number — React ส่งค่าล่าสุดให้ทุกครั้งPart 7: useReducer — Type-Safe State Machine
7.1 Discriminated Action
useReducer เปล่งประกายกับ TypeScript เมื่อ type ของ action เป็น discriminated union — แยกตาม type ของ action แต่ละตัวมี payload ของตัวเอง ทำให้ใน reducer TS narrow type ได้ (รู้ว่า action.payload เป็นอะไรในแต่ละ case) เป็น state machine ที่ type-safe:
typescript
type State = { count: number; loading: boolean };
type Action =
| { type: "increment"; payload: number }
| { type: "decrement" }
| { type: "setLoading"; payload: boolean }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + action.payload };
case "decrement":
return { ...state, count: state.count - 1 };
case "setLoading":
return { ...state, loading: action.payload };
case "reset":
return { count: 0, loading: false };
default:
return assertNever(action); // ← exhaustive check — ดู Part 8 สำหรับคำอธิบายเต็ม
}
}
// ⚠️ โค้ดนี้ต้อง define assertNever ก่อนจึงจะ run ได้ — นิยามสั้น ๆ ไว้ด้านล่างนี้
// (Part 8 จะอธิบายว่าทำงานยังไงและทำไมถึงสำคัญ)
function assertNever(value: never): never {
throw new Error("Unhandled case: " + JSON.stringify(value));
}
const [state, dispatch] = useReducer(reducer, { count: 0, loading: false });
dispatch({ type: "increment", payload: 5 });7.2 Action Creator
typescript
const actions = {
increment: (n: number) => ({ type: "increment" as const, payload: n }),
decrement: () => ({ type: "decrement" as const }),
};
type Action = ReturnType<typeof actions[keyof typeof actions]>;
// = { type: "increment"; payload: number } | { type: "decrement" }Part 8: Exhaustive Check (อาวุธลับ)
typescript
function assertNever(value: never): never {
throw new Error("Unhandled case: " + JSON.stringify(value));
}
// ใช้ใน reducer / switch
switch (action.type) {
case "a": return ...;
case "b": return ...;
default: return assertNever(action); // ← ถ้าเพิ่ม case "c" แต่ลืม → TS error
}ทำให้ TS รับประกันว่า case ครบ — เพิ่ม action ใหม่ → TS บอกทุกที่ที่ลืมจัดการ
Part 9: Custom Hooks Type-Safe
9.1 Hook ที่ return tuple
tuple คือ array ที่ทุกตำแหน่งมี type ชัดเจน เช่น [boolean, () => void] — ต่างจาก array ทั่วไปที่ทุกตัวมี type เดียวกัน เหมือน useState ที่คืน [value, setter]
custom hook ที่ return หลายค่ามักคืนเป็น tuple เพื่อให้ผู้ใช้ตั้งชื่อตัวแปรเองได้
แต่ต้องใส่ as const ไม่งั้น TS จะ infer เป็น array แบบ union ((boolean | (() => void))[]) แทนที่จะเป็น tuple ที่แต่ละตำแหน่งมี type ชัด:
typescript
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle] as const; // ← as const
}
const [open, toggleOpen] = useToggle();
// open: boolean
// toggleOpen: () => voidas const ทำให้ return เป็น tuple [boolean, () => void] ไม่ใช่ array (boolean | (() => void))[] (ถ้าไม่ใส่ as const TS จะ infer เป็น array แบบ union ที่ทุกตัวเป็น type เดียวกัน — tuple จะหาย)
9.2 Generic Hook
hook ที่ใช้ได้กับข้อมูลหลาย type (เช่น useLocalStorage เก็บอะไรก็ได้) ใช้ generic <T> — type จะ infer จาก initial value หรือระบุชัดตอนเรียก (useLocalStorage<User>(...)) ทำให้ value ที่ได้กลับมา type-safe ตามที่เก็บ:
⚠️ React Web เท่านั้น —
localStorageเป็น Web API ที่ React Native ไม่มี สำหรับ React Native ให้ใช้AsyncStorageแทน (ดูบท 12)
typescript
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
// SSR check: localStorage ไม่พร้อมใน server environment
if (typeof window === 'undefined') return initial;
try {
const stored = localStorage.getItem(key);
// try/catch ป้องกัน JSON.parse crash ถ้า stored value corrupt หรือ schema เปลี่ยน
return stored ? (JSON.parse(stored) as T) : initial;
} catch {
return initial;
}
});
useEffect(() => {
if (typeof window === 'undefined') return; // กันไว้เผื่อ effect ถูกเรียกในบริบทที่ไม่มี window
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
const [user, setUser] = useLocalStorage<User | null>("user", null);💡 production tip: ใช้ Zod validate ค่าที่อ่านจาก storage ก่อนใช้จริง เพื่อป้องกัน schema เปลี่ยนแล้วได้ค่าเก่าที่ไม่ตรง type:
UserSchema.parse(JSON.parse(stored))
9.3 Hook กับ async
วิธี type async state ที่ดีที่สุดคือ discriminated union ตาม status — แทน boolean หลายตัว (isLoading, isError) ที่อาจขัดกันเอง ให้ใช้ state เดียวที่เป็น idle/loading/success/error ผลคือ TS narrow type ให้: ใน case success จึงเข้าถึง data ได้, ใน error เข้าถึง error ได้ โดยไม่ต้อง optional chain:
typescript
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetch(url)
.then((r) => r.json())
.then((data) => !cancelled && setState({ status: "success", data }))
.catch((error) => !cancelled && setState({ status: "error", error }));
return () => { cancelled = true; };
}, [url]);
return state;
}
// usage
const result = useFetch<User>("/api/user/1");
if (result.status === "loading") return <Spinner />;
if (result.status === "error") return <Error error={result.error} />;
if (result.status === "success") return <UserCard user={result.data} />; // ✅ data is User
return null;→ ใช้ discriminated union ผ่าน status → TS narrow type ให้ใช้ field ที่ถูก
Part 10: Context with TypeScript
10.1 ปัญหา default value
createContext บังคับให้ใส่ default value แต่ context ส่วนใหญ่ไม่มี default ที่สมเหตุสมผล — ใส่ object dummy ก็ผิดความหมาย, ใส่ null ก็ต้อง optional chain ทุกที่ หัวข้อถัดไปแก้ด้วยการตั้ง type เป็น T | undefined + custom hook ที่ throw ถ้าใช้นอก provider:
typescript
const ThemeContext = createContext<Theme>(/* ???? */);
// ถ้าใส่ object dummy → ผิดความหมาย
// ถ้าใส่ null → ทุกคน optional chain (?. = อ่าน property โดยถ้าค่าเป็น null/undefined จะคืน undefined แทน crash เช่น `ctx?.color`) ตลอด10.2 แก้ด้วย "ห้ามใช้นอก provider"
typescript
type Theme = { color: string; toggle: () => void };
const ThemeContext = createContext<Theme | undefined>(undefined);
export function useTheme(): Theme {
const ctx = useContext(ThemeContext);
if (ctx === undefined) {
throw new Error("useTheme must be used within ThemeProvider");
}
return ctx;
}
export function ThemeProvider({ children }: PropsWithChildren) {
const [color, setColor] = useState("light");
const value = useMemo<Theme>(() => ({
color,
toggle: () => setColor((c) => c === "light" ? "dark" : "light")
}), [color]);
// React 19: <Context> ตรงๆ ไม่ต้อง .Provider
// React 18: ใช้ <ThemeContext.Provider value={value}> แทน (React 19 syntax จะ error ใน React 18)
return <ThemeContext value={value}>{children}</ThemeContext>;
}→ useTheme() มี type Theme ตรง — ไม่ต้อง check undefined
Part 11: Type-Safe Form (React Hook Form + Zod)
bash
npm install zod react-hook-form @hookform/resolverstypescript
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email(),
age: z.number().min(0).max(150),
role: z.enum(["admin", "user", "guest"]),
});
type FormData = z.infer<typeof schema>;
// = { email: string; age: number; role: "admin" | "user" | "guest" }
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
// data is fully typed
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
{/* ... */}
</form>
);
}→ 1 schema = validation + type — single source of truth
Part 12: Type-Safe Data Fetching
bash
npm install @tanstack/react-query zod⚠️ TanStack Query ต้อง setup
QueryClientProviderที่ root ก่อนจึงจะใช้useQueryได้ — ดูวิธี setup ใน TanStack Query docs หรือบทที่สอน Data Fetching
typescript
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Failed");
return UserSchema.parse(await res.json()); // ← validate runtime!
}
function useUser(id: number) {
return useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
}
// usage
const { data: user, isLoading, error } = useUser(1);
// user: User | undefined
// isLoading: boolean⚠️ TypeScript เป็น compile-time เท่านั้น — ตอน runtime TS หายไปหมด ถ้า backend ส่ง field ที่ไม่ตรง type → ไม่มีใครเตือน → runtime error เงียบ ๆ ใช้หลักการเดิมกับ Zod เหมือน Part 11:
schema.parse(data)validate ตอน runtime → ปลอดภัยทั้งสองชั้น
Part 13: Utility Types สำคัญ
13.1 Built-in
TypeScript มี utility type สำเร็จที่ใช้ประจำใน React — Partial/Required/Readonly แปลง modifier, Pick/Omit เลือก/ตัด field, Record สร้าง map type จำชุดนี้ได้ช่วยให้สร้าง type ใหม่จาก type เดิมโดยไม่ต้องเขียนซ้ำ (เช่น Omit<User, "password"> = User ที่ไม่มี password):
typescript
Partial<T> // ทุก field optional
Required<T> // ทุก field required
Readonly<T> // ทุก field readonly
Pick<T, K> // เลือกบาง field
Omit<T, K> // ตัดบาง field
Record<K, V> // { [k in K]: V }
ReturnType<F> // return ของ function
Parameters<F> // parameter ของ function
Awaited<T> // unwrap Promise<T>ตัวอย่าง:
typescript
type User = { id: number; name: string; email: string; password: string };
type PublicUser = Omit<User, "password">;
type UserUpdate = Partial<Omit<User, "id">>;
type UsersMap = Record<string, User>;13.2 React-specific
React มี utility type ของตัวเองที่ใช้บ่อย — ComponentProps<T> ดึง props ของ component/element ใดก็ได้ (ดีตอน wrap component อื่น), HTMLAttributes/ButtonHTMLAttributes ดึง attr ของ HTML element, CSSProperties สำหรับ style object รู้จักไว้จะ type props ได้เร็วโดยไม่ต้องเขียนเอง:
typescript
ComponentProps<T> // ทุก prop รวม ref
ComponentPropsWithRef<T> // เหมือนข้างบน
ComponentPropsWithoutRef<T> // ไม่รวม ref
PropsWithChildren<P> // P + { children?: ReactNode }
PropsWithRef<P> // P + ref
HTMLAttributes<HTMLDivElement> // attr ของ div
ButtonHTMLAttributes<HTMLButtonElement>
InputHTMLAttributes<HTMLInputElement>
SVGAttributes<SVGElement>
CSSProperties // style object13.3 NoInfer<T> (TS 5.4+)
NoInfer<T> = บอก TypeScript ว่า "อย่า infer generic จาก parameter ตัวนี้" — ใช้เมื่อต้องการให้ผู้เรียกระบุ type เองอย่างชัดเจน แทนที่ TS จะเดาให้จาก argument ที่ส่งมา:
typescript
// ❌ ปัญหา: TS infer T จากทั้ง initial และ transform → บางครั้งได้ type กว้างเกิน
function createStore<T>(initial: T, transform: (v: T) => T) { ... }
// ✅ NoInfer: บังคับให้ infer T จาก initial เท่านั้น — transform ต้องตามมา
function createStore<T>(initial: T, transform: (v: NoInfer<T>) => NoInfer<T>) { ... }
createStore(0, (n) => n + 1); // T infer = number จาก initial
createStore("hi", (s) => s.length); // ❌ TS error: s.length เป็น number ไม่ใช่ string💡 ใช้บ่อยใน utility function ที่มี callback — ช่วยให้ type error ชัดและอยู่ในที่ที่ถูกต้อง
Part 14: Conditional + Mapped + Template Literal Types
14.1 Conditional Type
type ขั้นสูงที่ทำให้ TS ทรงพลัง — conditional type คือ "if/else ระดับ type" (T extends X ? A : B) ใช้ตัดสิน type ตามเงื่อนไข ส่วนใหญ่เจอใน library แต่เข้าใจไว้ช่วยอ่าน type ของ lib ที่ใช้ออก:
typescript
type IsArray<T> = T extends Array<any> ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false14.2 infer
(ต่างจาก type inference ทั่วไปที่พูดถึงใน Part 3.1 — นี่คือ
inferkeyword จริง ๆ ที่ใช้เฉพาะใน conditional type)
infer ใช้ใน conditional type เพื่อ "ดึง type ที่ซ่อนอยู่" ออกมาเป็นตัวแปร — เช่นดึง element type จาก array, return type จาก function การเข้าใจ infer ช่วยให้สร้าง utility type ที่แกะ type ซับซ้อนได้:
typescript
type ElementType<T> = T extends Array<infer U> ? U : never;
type X = ElementType<number[]>; // number
type Y = ElementType<User[]>; // User14.3 Mapped Type
mapped type คือการ "วนทุก key ของ type แล้วแปลง" — เป็นเบื้องหลังของ Partial/Readonly และทำ transform ซับซ้อนได้ (เช่นสร้าง getter จากทุก field) เมื่อรวมกับ template literal key (get${...}) จะทรงพลังมากในการ generate type:
typescript
type Optional<T> = { [K in keyof T]?: T[K] };
// = Partial<T>
type WithGetters<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K] };
type User = { name: string; age: number };
type UserGetters = WithGetters<User>;
// = { getName: () => string; getAge: () => number }14.4 Template Literal Type
typescript
type CssProp = `--${string}`;
const x: CssProp = "--primary-color"; // ✅
const y: CssProp = "primary-color"; // ❌ใช้ทำ event name, CSS prop, key constraint
Part 15: Strict Mode Migration
ทำ project ที่มี TS ให้ strict ขึ้น:
tsconfig.json:
json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true, // arr[i] → T | undefined
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}noUncheckedIndexedAccess (ไม่ยอมเข้าถึง array/object index แบบไม่ check undefined) — เปิดแล้ว arr[i] จะมี type T | undefined บังคับให้ check ก่อนใช้ → กัน runtime crash ตอน index out of range:
typescript
const first = arr[0]; // type: T | undefined
if (first) console.log(first.name);→ ปลอดภัยขึ้นเยอะ — ราคาคือต้อง check undefined
Part 16: Common Pitfalls
16.1 any vs unknown
any ปิด type checking ทั้งหมด (เหมือนไม่มี TS) — ส่วน unknown บอกว่า "ไม่รู้ type" แต่บังคับให้ narrow ก่อนใช้ กฎคือใช้ unknown แทน any เสมอเมื่อไม่แน่ใจ type เพราะมันปลอดภัยกว่ามาก (ยังต้องพิสูจน์ type ก่อนเข้าถึง property):
typescript
function process(data: any) { console.log(data.email); } // ❌ no check
function process(data: unknown) { console.log(data.email); } // ❌ TS error — must narrow firstRule: ใช้ unknown แทน any เสมอ
16.2 as (Type Assertion)
as คือการ "บอก TS ว่าเชื่อฉันเถอะ type นี้คือ X" — แต่มันไม่ตรวจตอน runtime ถ้าข้อมูลจริงไม่ตรง (เช่น API ส่ง field ผิด) ก็พังเงียบ ๆ ทางที่ปลอดภัยกว่าคือ validate จริงด้วย Zod (schema.parse) แทนการ assert ลอย ๆ:
typescript
const user = response as User; // ❌ trust runtime — อาจ wrong
const user = UserSchema.parse(response); // ✅ validate at runtime16.3 Type vs Value
ความสับสนของมือใหม่ TS — type/interface มีอยู่แค่ตอน compile (หายไปตอน runtime) จะ new หรือใช้เป็นค่าไม่ได้ ต่างจาก class ที่เป็นทั้ง type และ value เข้าใจเส้นแบ่งนี้ช่วยลด error ประเภท "X refers to a type but is being used as a value":
typescript
type User = { id: number };
const x = new User(); // ❌ User เป็น type ไม่ใช่ class16.4 Conditional rendering คืน null หรือ false
กับดักคลาสสิกของ React — {count && <div>} เมื่อ count เป็น 0 จะ render เลข "0" ออกมาบนจอ (เพราะ 0 เป็น falsy ที่ React แสดง ต่างจาก false/null ที่ไม่แสดง) แก้ด้วยการเขียนเงื่อนไขให้คืน boolean ชัด ๆ (count > 0 && ...) หรือใช้ ternary คืน null:
typescript
// ❌ ปัญหา: เมื่อ count = 0, JavaScript คำนวณ 0 && ... = 0 → React render เลข "0" บนจอ
{count && <div>{count}</div>}
// ↑ 0 เป็น falsy แต่ React render เลข 0 จริง ๆ (ต่างจาก false/null ที่ไม่ render อะไร)
// ✅ แก้แบบที่ 1: ใช้ > 0 เพื่อให้ได้ boolean จริง ๆ (false → ไม่ render)
{count > 0 && <div>{count}</div>}
// ✅ แก้แบบที่ 2: ternary คืน null ชัดเจน
{count > 0 ? <div>{count}</div> : null}Part 17: Lab
Lab 1: Generic Table Component
ลองรวมความรู้ทั้งบท — สร้าง <Table> แบบ generic ที่ type-safe: columns ต้องอ้าง key ที่มีจริงใน T เท่านั้น (keyof T) ถ้าพิมพ์ key ผิด TS จะเตือน เป็นโจทย์ที่ฝึก generic + constraint + keyof พร้อมกัน:
ทำ <Table> ที่:
- รับ
data: T[] - รับ
columns: { key: keyof T; label: string }[]— only valid keys allowed - รับ
renderCell?: (item: T, col: keyof T) => ReactNode
Lab 2: Type-safe Modal
โจทย์ที่ท้าทายขึ้น — ทำ modal system ที่ openModal('confirm', payload) บังคับให้ payload ตรงกับ type ของ modal นั้น (modal คนละชนิดรับ payload คนละแบบ) ฝึกใช้ generic + mapped type ผูก name เข้ากับ payload type:
ทำ modal system ที่ open ตาม name + type-safe payload:
typescript
type ModalRegistry = {
confirm: { message: string; onConfirm: () => void };
alert: { message: string };
user: { user: User };
};
const { open } = useModal<ModalRegistry>();
open("confirm", { message: "Sure?", onConfirm: () => {} });
open("user", { user });
open("confirm", { user }); // ❌ TS errorLab 3: Polymorphic Box
ทำ <Box as="div" />, <Box as="section" />, <Box as={Link} to="/x" /> แบบ type-safe เต็มที่
Part 18: Checkpoint
📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย — เป็นคำถามทบทวนให้ตอบเองจากเนื้อหาในบท ถ้าตอบไม่ได้ ให้ย้อนกลับไปอ่าน section ที่เกี่ยวข้อง
typevsinterfaceใช้ตอนไหน?as constทำอะไร?satisfiesต่างจากasยังไง?- Discriminated union คืออะไร? ใช้ทำอะไรใน React?
- Polymorphic component ทำงานยังไง?
ComponentPropsWithoutRef<"button">คืออะไร?- Exhaustive check ใช้ทำอะไร?
as constใน custom hook return ทำไมจำเป็น?- Zod +
z.inferใช้ทำอะไร? unknownต่างanyยังไง?
Part 19: สรุปบทนี้
- TypeScript ใน React 2026 = บังคับสำหรับ professional project
- type vs interface — type สำหรับ props, interface สำหรับ library API
as const+satisfies= TS รุ่นใหม่- Discriminated union = type-safe state machine + props pattern
- Generic component = reusable + type-safe (constraint, infer)
- Polymorphic component = ของ design system advanced
- Zod schema = single source of truth สำหรับ validation + type
- Exhaustive check = หาเคสที่ลืม
- Strict mode +
noUncheckedIndexedAccess= type safety สูงสุด NoInfer<T>(TS 5.4+) = ป้องกัน TS infer generic จาก parameter บางตัว — ใช้บังคับให้ user ใส่ type เอง (ดู Part 13.3)- Branded types = nominal typing pattern สำหรับ ID safety (
type UserId = string & { __brand: 'UserId' }) - Schema-first (Zod → type ผ่าน
z.infer) = single source of truth ระหว่าง runtime validation + compile-time type satisfies+as const= pattern ที่ใช้บ่อยที่สุดใน config object — keep literal type พร้อม check shape
บทถัดไป — React Internals (Fiber, Reconciliation, Concurrent Rendering) — ใต้พรมของ React