Mapped Types
loop บน key
หัวข้อที่มีชื่อว่า “loop บน key”mapped type วนซ้ำบน key ของ type แล้วสร้าง type ใหม่ syntax คล้าย object type แต่ใส่ [K in ...] ตรงตำแหน่งที่ key ควรอยู่ — เป็น for loop ระดับ type
type Flags = { [K in "a" | "b" | "c"]: boolean;};// ^? { a: boolean; b: boolean; c: boolean }K ไล่ไปทีละ member ของ union "a" | "b" | "c" และสำหรับแต่ละตัว type ก็สร้าง property ออกมา รวมกับ keyof แล้วคุณวนซ้ำบน type ที่มีอยู่ได้:
interface User { id: number; name: string; }
type Stringify = { [K in keyof User]: string;};// ^? { id: string; name: string }อ้างถึง value type เดิม
หัวข้อที่มีชื่อว่า “อ้างถึง value type เดิม”ภายใน loop T[K] (indexed access) ให้ value type เดิม — คุณจึง transform แทนที่จะแทนที่ทั้งหมดได้:
interface User { id: number; name: string; }
type Getters<T> = { [K in keyof T]: () => T[K];};
type UserGetters = Getters<User>;// ^? { id: () => number; name: () => string }แต่ละ property กลายเป็น function ที่คืน value type นั้น รูปแบบ “เก็บ key ไว้ transform value” คือการใช้ mapped type ในชีวิตประจำวัน
mapping modifier: เพิ่มหรือลบ readonly และ ?
หัวข้อที่มีชื่อว่า “mapping modifier: เพิ่มหรือลบ readonly และ ?”mapped type เปลี่ยน modifier ของแต่ละ property ได้ด้วย คุณเพิ่ม readonly หรือ ? ด้วยการเขียนลงไปตรง ๆ และ ลบ ด้วย prefix -
// Make every property optional (this is how Partial works)type MyPartial<T> = { [K in keyof T]?: T[K];};
// Make every property readonly (this is Readonly)type MyReadonly<T> = { readonly [K in keyof T]: T[K];};
// Strip optional AND readonly off every propertytype Concrete<T> = { -readonly [K in keyof T]-?: T[K];};flowchart LR
src["T: { id: number, name: string }"] -->|"for each K in keyof T"| loop["apply transform:
modifier + value type"]
loop --> out["new type:
{ id?: number, name?: string }"] -? ลบ optional และ -readonly ลบ immutability — นั่นคือวิธีที่ Required และ utility แบบ mutable-clone ถูกเขียนพอดี เวลาเห็น -? ใน library แปลว่า “บังคับให้ทุก property มีอยู่จริง”
key remapping ด้วย as
หัวข้อที่มีชื่อว่า “key remapping ด้วย as”feature ที่ใหม่กว่าให้คุณ rename หรือ filter key ระหว่าง map ด้วย clause as นี่คือวิธีสร้างอย่าง type “getters” ที่มีชื่อขึ้นต้น get หรือ drop key ที่ value type ไม่ต้องการ:
// Rename each key to get<Key>type Getters2<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];};
type UG = Getters2<{ name: string }>;// ^? { getName: () => string }
// Filter: keep only string-valued properties (remap unwanted keys to never)type StringProps<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K];};การ remap key เป็น never จะ ลบ key นั้นออกจากผลลัพธ์ — เป็นทริกมาตรฐานสำหรับ filter property ตาม value type
Homomorphic mapped type: modifier เดินทางไปกับ shape
หัวข้อที่มีชื่อว่า “Homomorphic mapped type: modifier เดินทางไปกับ shape”mapped type เรียกว่า homomorphic เมื่อมัน map ตรง ๆ บน [K in keyof T] สำหรับ type parameter เดี่ยว ๆ ที่เปลือย T — ไม่มีอะไรห่อรอบ keyof T ไม่มีอะไรผสมเข้ามาอีก mapped type แบบ homomorphic ได้ feature พิเศษที่ mapped type แบบอื่นไม่ได้: มัน copy modifier readonly กับ ? จาก type ต้นทางอัตโนมัติ ซ้อนทับกับ modifier ที่ mapped type เองเพิ่มเข้าไป
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface Source { readonly a: string; b?: number;}
// Homomorphic: maps directly over keyof Ttype Copy<T> = { [K in keyof T]: T[K] };type Copied = Copy<Source>;
// `a` is still readonly -- this assignment is a real TS2540, proving itdeclare const copied: Copied;// @ts-expect-error -- readonly a is preserved from Sourcecopied.a = "changed";
// `b` is still optional -- omitting it compilesconst noB: Copied = { a: "x" };เทียบกับ mapped type ที่ key มาจาก type parameter ธรรมดาแยกต่างหาก — ไม่ใช่ keyof T ข้างในอันเดียวกัน:
// Non-homomorphic: K is just a string-constrained parameter, not keyof T heretype FromKeys<K extends string, V> = { [P in K]: V };
interface Source { readonly a: string; b?: number;}
type NotCopied = FromKeys<keyof Source, Source[keyof Source]>;
declare const notCopied: NotCopied;notCopied.a = "changed"; // ✅ compiles -- readonly was NOT preserved// @expect-error TS2741interface Source { readonly a: string; b?: number;}
type FromKeys<K extends string, V> = { [P in K]: V };type NotCopied = FromKeys<keyof Source, Source[keyof Source]>;
const missingB: NotCopied = { a: "x" }; // ❌ b is now REQUIRED -- optional was NOT preservedนี่ไม่ใช่นิทานลอย ๆ มันคือความต่างระหว่าง utility type สองตัวที่คุณรู้จักอยู่แล้วเป๊ะ Partial<T> = { [K in keyof T]?: T[K] } เป็น homomorphic นั่นคือเหตุผลที่ Partial ของ type ที่มี property readonly ยังคง readonly ไว้ Record<K, V> = { [P in K]: V } ไม่ เป็น homomorphic — K ของมันเป็น parameter ที่ constrain ด้วย keyof any เปล่า ๆ ไม่มีอะไรให้ copy modifier มา ดังนั้น Record ให้ property แบบ mutable และ required เสมอ ไม่มีทาง readonly หรือ ? เลย ไม่ว่าคุณจะส่งอะไรเข้าไป การห่อ keyof T ด้วยอะไรก็ตาม — แม้แต่อะไรที่ดูไม่มีพิษภัยอย่าง Extract<keyof T, string> — ก็ปิด homomorphism เหมือนกัน เพราะ mapped type ไม่ได้เป็น [K in keyof T] ตรง ๆ อีกต่อไป:
// @expect-error TS2741interface Source { readonly a: string; b?: number;}
// keyof T wrapped in Extract -- no longer the bare `keyof T` shape, so homomorphism is lost tootype Wrapped<T> = { [K in Extract<keyof T, string>]: T[K] };type WrappedResult = Wrapped<Source>;
const test: WrappedResult = { a: "x" }; // ❌ b is required againmapped type บน naked type parameter distribute ผ่าน union
หัวข้อที่มีชื่อว่า “mapped type บน naked type parameter distribute ผ่าน union”พฤติกรรมพิเศษอีกอย่างที่แยกกัน: เมื่อ bare type parameter T ของ mapped type แบบ homomorphic ถูก instantiate ด้วย union mapped type จะรันครั้งเดียวต่อ memberแล้ว union ผลลัพธ์เข้าด้วยกัน — สัญชาตญาณ distributive แบบเดียวกับ conditional type บน naked checked type แค่ใช้กับการ map แทนการ branch:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface A { a: number }interface B { b: string }
type Boxed<T> = { [K in keyof T]: T[K][] };
type Result = Boxed<A | B>;// ^? { a: number[] } | { b: string[] }
type _distributes = Expect<Equal<Result, { a: number[] } | { b: string[] }>>;อาจดูแปลกถ้าคุณคาดว่าจะเป็นการคำนวณแบบ keyof (A | B) — keyof (A | B) เดี่ยว ๆ คือ never (ไม่มี key ไหนร่วมกันทั้งคู่ เพราะ A กับ B ไม่แชร์ชื่อ property กันเลย) ซึ่งจะทำให้ mapped type ที่ไม่ distribute ยุบเหลือ {} แต่เพราะ T ของ Boxed<T> เป็น naked TypeScript distribute mapped type ทั้งอันผ่าน union ก่อนจะคำนวณ keyof บนแต่ละชิ้น — รัน Boxed<A> กับ Boxed<B> แยกกันแล้ว union object type ทั้งสองผลลัพธ์เข้าด้วยกัน เหมือนกับกิ่งของ distributive conditional ทีละ member เป๊ะ
array กับ tuple map ไปเป็น array กับ tuple
หัวข้อที่มีชื่อว่า “array กับ tuple map ไปเป็น array กับ tuple”mapped type แบบ homomorphic ที่ใช้กับ array หรือ tuple type ไม่ได้เปลี่ยนมันเป็น object ที่มี key เป็นตัวเลข — มันสร้าง array หรือ tuple อีกตัว รักษา shape เดิมไว้เป๊ะ (รวมถึงความยาวของ tuple และตำแหน่งของแต่ละ element):
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type Boxed<T> = { [K in keyof T]: T[K][] };
type TupleResult = Boxed<[string, number]>;type _tupleCheck = Expect<Equal<TupleResult, [string[], number[]]>>;
type ArrResult = Boxed<string[]>;type _arrCheck = Expect<Equal<ArrResult, string[][]>>;Boxed<[string, number]> กลายเป็น [string[], number[]] — tuple สององค์ประกอบ ไม่ใช่ { 0: string[]; 1: number[]; length: number[] } นี่คือ homomorphic special-casing แบบเดียวกันที่ทำงานอยู่: TypeScript รู้จัก array กับ tuple ว่าเป็น shape ที่คุ้มค่ารักษาไว้ ไม่ใช่แค่ object ที่มี key หน้าตาเหมือนตัวเลข
Homomorphic mapped types preserve modifiers and shape; wrapping keyof T loses both
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface Source { readonly a: string; b?: number;}
// homomorphic: readonly + optional both preservedtype Copy<T> = { [K in keyof T]: T[K] };declare const copied: Copy<Source>;const okOmit: Copy<Source> = { a: "x" }; // b is still optional
// non-homomorphic: readonly and optional are both LOSTtype FromKeys<K extends string, V> = { [P in K]: V };type NotCopied = FromKeys<keyof Source, Source[keyof Source]>;declare const notCopied: NotCopied;notCopied.a = "changed"; // allowed -- readonly is gone
// naked T distributes over a union before keyof is computed per-branchinterface A { a: number }interface B { b: string }type Boxed<T> = { [K in keyof T]: T[K][] };type Result = Boxed<A | B>;type _distributes = Expect<Equal<Result, { a: number[] } | { b: string[] }>>;
// arrays/tuples map to arrays/tuples, not numeric-keyed objectstype TupleResult = Boxed<[string, number]>;type _tupleCheck = Expect<Equal<TupleResult, [string[], number[]]>>; Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
bare["[K in keyof T] -- T คือ bare type parameter เดี่ยว ๆ"] --> homo["Homomorphic: copy readonly/optional จาก T, รักษา shape ของ array/tuple"]
wrapped["[K in SomeExpression<keyof T>] หรือ [P in K] สำหรับ K ที่ไม่เกี่ยวข้อง"] --> nonhomo["ไม่ homomorphic: สร้าง object เปล่าใหม่ -- ไม่มี modifier ให้ copy"]
homo --> union{"T ถูก instantiate ด้วย union ไหม?"}
union -->|ใช่| dist["Distribute: รันครั้งเดียวต่อ member แล้ว union ผลลัพธ์"] คำถามที่ควรถามกับ mapped type ไหนก็ตามไม่ใช่ “มันใช้ keyof หรือเปล่า” แต่คือ “keyof T คือทั้งหมดของสิ่งที่ถูก map โดย T เป็น bare parameter เดี่ยว ๆ หรือเปล่า” Partial, Required, Readonly และ Pick ทั้งหมดผ่านเงื่อนไขนี้ — นั่นคือเหตุผลที่มัน preserve readonly/? และ shape ของ array/tuple และ distribute ผ่าน union ที่ส่งเข้าไป Record ไม่ผ่านเงื่อนไขนี้ เพราะ key parameter ของมันไม่เคยเป็น keyof ของ value type ตั้งแต่แรก
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
form homomorphic ({ [K in keyof T]: ... }) | preserve readonly/? และ shape ของ array/tuple อัตโนมัติ และ distribute ผ่าน union ได้ฟรี | ใช้ได้แค่เมื่อมี source type T ตัวเดียวให้ copy modifier มา — ใช้สร้าง type ที่ key มาจาก string union อะไรก็ได้ที่ไม่เกี่ยวกับ object ไหนเลยไม่ได้ |
form non-homomorphic ({ [P in K]: V }) | สร้าง type จาก key set อะไรก็ได้ รวมถึงตัวที่ไม่มี object type ตรงกันเลย — นี่คือ Record เป๊ะ | ให้ property แบบ mutable และ required เสมอ — ไม่มีทางทำให้มัน preserve modifier ได้ เพราะไม่มี source object ให้ copy มา |
| พึ่งการ distribute อัตโนมัติของ union | ได้พฤติกรรมทีละ member ฟรีสำหรับ union ของ object ที่ key ไม่ทับกัน — ไม่ต้องใช้ยิมนาสติก Exclude/conditional เลย | ลืมง่ายว่ามันกำลังเกิดขึ้น — mapped type ที่ “ควร” ให้ object รวมเป็นก้อนเดียวกลับให้ union แบบเงียบ ๆ กิ่งละหนึ่งต่อ member ที่ป้อนเข้าไป |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- mapped type ไหนก็ตามที่พูดถึง
keyof Tเป็น homomorphic และ preserve modifier มีแค่ mapped type ที่ key expressionทั้งหมดเป็นkeyof Tเปลือย ๆ ของ type parameter เดี่ยวTเท่านั้นที่ผ่านเงื่อนไข การห่อมันด้วยExtract<keyof T, string>หรือดึงkeyof Tออกไปเป็น type parameter แยกก่อน ทั้งสองแบบปิด homomorphism — modifier หายทั้งคู่ Record<K, V>ควร preservereadonly/?ได้ถ้าคุณส่งkeyof Tที่มาจาก type ที่มี modifier พวกนั้น มันทำไม่ได้ — พอถึงเวลาที่Recordเห็นKมันเป็นแค่ union ของ literal string/number/symbol ไม่มีความทรงจำเลยว่ามาจาก object type ไหน (ถ้ามี) และไม่มี modifier ให้ copy- mapped type บน union ของ object คำนวณ
keyofของทั้ง union ก่อน ดังนั้น union ของ object ที่ key ไม่ทับกันเลยจะยุบเหลือ{}จริงแค่ตอนที่ type parameterไม่เป็น naked (เช่น resolve เป็น union เจาะจงไปแล้วก่อนถึง mapped type)Tที่ naked เปลือย ๆ distribute การ map ทั้งอันผ่านแต่ละ union member ก่อน แล้วค่อยคำนวณkeyofต่อ member — นั่นคือเหตุผลที่ union ที่ key ไม่ทับกันยังให้ผลลัพธ์ต่อ member ที่สมเหตุสมผล ไม่ใช่{}
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- utility “clone พร้อม readonly เพิ่ม” หรือ “deep partial” ที่เขียนมือแล้วดูถูกต้องกับ object ธรรมดา มักพังแบบเงียบ ๆ ทันทีที่มีคนส่ง type ที่มี property
readonlyหรือ optional เข้ามา — ถ้า mapped type ส่ง key ผ่านอะไรที่ไม่ใช่keyof Tเปลือย ๆ ตรง ๆ ให้เช็ค case readonly/optional โดยเฉพาะ เพราะ compiler จะไม่เตือนคุณว่า homomorphism หายไปแล้ว - generic utility ที่สร้างเป็น
{ [K in SomeKeys]: SomeValue }ที่SomeKeysถูกคำนวณใน type alias ก่อนหน้า (แทนที่จะเป็นkeyof Tที่เขียนตรง ๆ ในอันนี้) preserve modifier ต้นทางไม่ได้เลย แม้ comment ของผู้เขียนจะอ้างว่าทำได้ — claim นั้นต้องมี probe เช็ค readonly/optional assignability ไม่ใช่แค่อ่านผ่าน ๆ - โค้ดที่คาดหวังว่า mapped type บน union ของ object type จะให้ object รวมเป็นก้อนเดียว (“union ทุกอย่างเข้าด้วยกันเลย”) กำลังฝืนพฤติกรรมจริงของ mapped type:
Tที่ naked distribute ทีละ member แทน ซึ่งมักเป็นสิ่งที่คุณต้องการอยู่แล้ว แต่ควรพูดออกมาชัด ๆ ใน comment ไม่งั้นคนอ่านคนถัดไปอาจ “แก้” มันด้วยการห่อTด้วยวงเล็บจนกลายเป็น form ที่ไม่ distribute
🐛 หา bug ในโค้ดนี้
interface Config {readonly apiUrl: string;timeout?: number;}
// "DeepPartial-lite": intended to keep readonly/optional exactly like Config, just re-typedtype ConfigKeys = keyof Config;type RebuildConfig<K extends ConfigKeys, V> = { [P in K]: V };
type Rebuilt = RebuildConfig<ConfigKeys, Config[ConfigKeys]>;
declare const rebuilt: Rebuilt;rebuilt.apiUrl = "https://changed.example"; // should this be allowed?เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย รวมถึงการ reassign ในบรรทัดสุดท้ายด้วย — แต่นั่นแหละคือ bug ไม่ใช่การผ่านที่สะอาด RebuildConfig ดึง keyof Config ออกไปเป็น type alias แยก (ConfigKeys) ก่อนที่จะ map บนมัน ดังนั้นพอถึงตอนที่ [P in K] ของ RebuildConfig เองรัน K เป็นแค่ bare string-literal-union type parameter ที่ไม่เชื่อมกับ Config เลย นั่นทำลาย homomorphism ไปเลยทั้งหมด: readonly ของ apiUrl และ ? ของ timeout หายไปแบบเงียบ ๆ ทั้งคู่ ทำให้ Rebuilt จบลงด้วยทุก property เป็น mutable และ required — ตรงข้ามกับสิ่งที่ utility “keep it like Config” ควรทำเป๊ะ ทางแก้คือทำให้ตัว mapped type เองเป็นตัวที่เขียน keyof T: type RebuildConfig<T> = { [K in keyof T]: T[K] } ใช้เป็น RebuildConfig<Config> ซึ่งเป็น homomorphic (keyof T เปลือย ๆ ของ parameter เดี่ยว T) และ preserve apiUrl readonly กับ timeout optional ได้ถูกต้อง — verify ด้วย assignment probe แบบเดียวกับที่ใช้ข้างบน เพราะ compiler ไม่ให้ error ทั้งสองแบบ แค่ “มัน type-check ผ่านไหม” ไม่พอ