ข้ามไปยังเนื้อหา

Mapped Types

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 }

ภายใน 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 ในชีวิตประจำวัน

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 property
type 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 }"]
mapped type transform แต่ละ property

-? ลบ optional และ -readonly ลบ immutability — นั่นคือวิธีที่ Required และ utility แบบ mutable-clone ถูกเขียนพอดี เวลาเห็น -? ใน library แปลว่า “บังคับให้ทุก property มีอยู่จริง”

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

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 T
type Copy<T> = { [K in keyof T]: T[K] };
type Copied = Copy<Source>;
// `a` is still readonly -- this assignment is a real TS2540, proving it
declare const copied: Copied;
// @ts-expect-error -- readonly a is preserved from Source
copied.a = "changed";
// `b` is still optional -- omitting it compiles
const noB: Copied = { a: "x" };

เทียบกับ mapped type ที่ key มาจาก type parameter ธรรมดาแยกต่างหาก — ไม่ใช่ keyof T ข้างในอันเดียวกัน:

// Non-homomorphic: K is just a string-constrained parameter, not keyof T here
type 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 TS2741
interface 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 TS2741
interface Source {
readonly a: string;
b?: number;
}
// keyof T wrapped in Extract -- no longer the bare `keyof T` shape, so homomorphism is lost too
type Wrapped<T> = { [K in Extract<keyof T, string>]: T[K] };
type WrappedResult = Wrapped<Source>;
const test: WrappedResult = { a: "x" }; // ❌ b is required again

พฤติกรรมพิเศษอีกอย่างที่แยกกัน: เมื่อ 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 เป๊ะ

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 preserved
type 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 LOST
type 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-branch
interface 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 objects
type TupleResult = Boxed<[string, number]>;
type _tupleCheck = Expect<Equal<TupleResult, [string[], number[]]>>;
Open in TS Playground
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 ผลลัพธ์"]
Homomorphic mapping copies modifiers and shape; anything else starts from scratch

คำถามที่ควรถามกับ 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> ควร preserve readonly/? ได้ถ้าคุณส่ง 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 ที่สมเหตุสมผล ไม่ใช่ {}
  • 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-typed
type 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 ผ่านไหม” ไม่พอ

mapped type คืออะไร?
ภายใน { [K in keyof T]: ... } จะอ้างถึง value type เดิมที่ key K อย่างไร?
modifier -? ใน mapped type ทำอะไร?
ใน clause as ของ key remapping เกิดอะไรขึ้นเมื่อ remap key เป็น never?
R1/R2 ตัวไหนที่เก็บ a เป็น readonly และ b เป็น optional?
interface Source { readonly a: string; b?: number; }
type Copy<T> = { [K in keyof T]: T[K] };
type FromKeys<K extends string, V> = { [P in K]: V };

type R1 = Copy<Source>;
type R2 = FromKeys<keyof Source, Source[keyof Source]>;
ทำไม Record<K, V> ถึง preserve modifier readonly หรือ ? ไม่ได้ แม้ K จะเป็น keyof SomeType?
Result คืออะไร?
interface A { a: number }
interface B { b: string }
type Boxed<T> = { [K in keyof T]: T[K][] };
type Result = Boxed<A | B>;