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

keyof & Indexed Access

operator keyof รับ object type แล้วสร้าง union ของ key ออกมาเป็น type

interface User {
id: number;
name: string;
active: boolean;
}
type UserKey = keyof User;
// ^? "id" | "name" | "active"

keyof User ไม่ใช่ value — แต่เป็น union type "id" | "name" | "active" นี่คือวัตถุดิบสำหรับการวนซ้ำบน type ซึ่งก็คือสิ่งที่ mapped type ทำพอดี

สำหรับ object type ที่มี index signature keyof จะให้ index type ออกมา:

type Dict = { [key: string]: number };
type DictKey = keyof Dict;
// ^? string | number (JS object keys can be accessed by number too)

พอมี key แล้ว indexed access จะ lookup type ของ value ที่ key นั้น — เวอร์ชันระดับ type ของ obj[key]

interface User {
id: number;
name: string;
}
type IdType = User["id"];
// ^? number
type NameType = User["name"];
// ^? string

index ด้วย union ของ key เพื่อเอา union ของ value type ก็ได้:

interface User {
id: number;
name: string;
}
type Values = User[keyof User];
// ^? number | string

User[keyof User] อ่านว่า “type ของทุก value ใน User” — เป็น idiom ที่ใช้บ่อยเพื่อเอา union ของ value type ทั้งหมดของ type

flowchart LR
  t["User { id: number, name: string }"] -->|keyof| keys["keys: id or name"]
  keys -->|"T[key]"| vals["value types: number or string"]
keyof เอา key, indexed access เอา value type

array และ tuple ก็เป็น object type เหมือนกัน indexed access จึงใช้ได้ — และ [number] คือทริกสำหรับ “element type”

type Nums = number[];
type Elem = Nums[number];
// ^? number
const tuple = [1, "two", true] as const;
type TupleElem = (typeof tuple)[number];
// ^? 1 | "two" | true
type First = (typeof tuple)[0];
// ^? 1

Arr[number] แปลว่า “index array นี้ด้วย number อะไรก็ได้” ซึ่งได้ union ของ element type ทั้งหมด — เป็นวิธีมาตรฐานในการเปลี่ยน array/tuple type ให้เป็น union

แทบทุก utility type สร้างจาก keyof + indexed access + mapped type:

interface User {
id: number;
name: string;
}
// Pick, from scratch — iterate chosen keys, look up each value type
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type NameOnly = MyPick<User, "name">;
// ^? { name: string }

K extends keyof T จำกัด key ให้เป็น key จริงของ T; [P in K] วนซ้ำ key เหล่านั้น (mapped type, บทเรียนถัดไปถ้าคุณข้ามมา); T[P] lookup value type แต่ละตัว สาม primitive ได้เครื่องมือที่มีประโยชน์หนึ่งตัว

keyof ไม่ได้จำกัดแค่ object type เจาะจง — คุณถามได้ว่า keyof any คืออะไร คำตอบคือเซตเต็มของสิ่งที่ JavaScript ยอมให้เป็น property key:

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 KA = keyof any;
// ^? string | number | symbol
type _kaCheck = Expect<Equal<KA, string | number | symbol>>;

นี่ไม่ใช่เรื่องบังเอิญ — property key ของ JavaScript จริง ๆ เป็นได้แค่ string, number (ที่ถูก coerce เป็น string ตอน runtime แต่ TypeScript แยกติดตามมันต่างหากสำหรับการ index tuple/array) หรือ symbol keyof any โผล่มาตลอดในฐานะ constraint ของ generic key parameter: Record<K extends keyof any, V> — นิยามจริงของ Record ใน lib — บอกว่า “K เป็นอะไรก็ได้ที่ object ของ JavaScript ใช้เป็น key ได้จริง” ซึ่งกว้างกว่า keyof ของ object type เจาะจงตัวใดตัวหนึ่ง

ข้างใน generic function keyof T resolve เป็น union ของ literal key ที่เป็นรูปธรรมไม่ได้ — T ยังไม่ใช่ type เจาะจง compiler เลยเก็บ keyof T เป็น expression ที่ deferred ไม่โปร่งใส จนกว่า function จะถูกเรียกด้วย type argument จริง ผลที่ตามมาที่ใช้ได้จริงมากคือ: Object.keys ถูก type ให้คืน (keyof T)[] ไม่ได้ ทั้งที่ดูเหมือนควรเป็นแบบนั้นเป๊ะ

// @expect-error TS2322
function describe<T extends object>(t: T) {
const keys: (keyof T)[] = Object.keys(t);
return keys;
}
error TS2322: Type 'string[]' is not assignable to type '(keyof T)[]'.
Type 'string' is not assignable to type 'keyof T'.
Type 'string' is not assignable to type 'never'.

มีสองเรื่องเกิดขึ้นตรงนี้ อย่างแรก Object.keys ถูก type ให้คืน string[] เต็ม ๆ — signature จริงใน lib ไม่ได้พยายามคืน (keyof T)[] เลยด้วยซ้ำ ด้วยเหตุผลเรื่อง soundness: ตอน runtime object ที่ type เป็น T มี key ได้มากกว่าที่ T ประกาศไว้ (structural typing ยอมให้ value จริงมี property เพิ่มได้ และ subclass หรือ object literal ที่ assign ผ่าน type ที่กว้างกว่าก็มี key ที่ T ไม่เคยพูดถึงได้) caller เลยสมมติไม่ได้ว่า key list ตอน runtime จะตรงกับ keyof T เป๊ะ อย่างที่สอง สังเกต error trail: TypeScript รายงานว่า keyof T assignable จาก string ไม่ได้ แล้วรายงาน keyof T ที่ยังdeferredเหมือนมันเป็น never ในบริบทนี้ ก็เพราะมันไม่มี type ที่เป็นรูปธรรมของ T ให้ resolve keyof T เทียบด้วยเลย นี่คือเหตุผลที่ pattern Object.keys(obj).forEach(k => ...) ที่ index กลับเข้า obj ด้วย k ในโลกจริงต้องมี cast หรือ key list ที่ validate ตอน runtime — type system ไม่ได้ดื้อ มันแค่ปฏิเสธถูกต้องที่จะสัญญาสิ่งที่มันพิสูจน์ไม่ได้

พอคุณ index เข้าไปใน union ของ object type ด้วย key indexed access จะเช็คแต่ละ member ของ union แยกกันแล้ว union ผลลัพธ์กลับเข้าด้วยกัน — สัญชาตญาณ distributive แบบเดียวกับ conditional type แค่ใช้กับการ lookup แทนการ 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;
type Data = { type: "a"; value: string } | { type: "b"; value: number };
type ValueType = Data["value"];
// ^? string | number
type _valueDistributes = Expect<Equal<ValueType, string | number>>;

Data["value"] อ่าน property value ของแต่ละ union member แยกกัน — string จากตัวแรก number จากตัวที่สอง — แล้ว union กันเป็น string | number นี่แหละคือสิ่งที่ทำให้ property ที่ discriminated union แชร์กันถูก index ได้อย่างปลอดภัยโดยไม่ต้อง narrow ก่อน และเป็นกลไกเดียวกับที่ Getters<T> กับทุก pattern mapped-type-over-a-union ในบทถัดไปพึ่งพา

keyof any, generic keyof ที่ deferred และ indexed access ที่ distribute ผ่าน union

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;
// keyof any: the full set of legal JS property key types
type KA = keyof any;
type _kaCheck = Expect<Equal<KA, string | number | symbol>>;
// keyof T stays deferred inside a generic -- Object.keys is always string[]
function describe<T extends object>(t: T) {
const keys = Object.keys(t);
type K = typeof keys;
type _isStringArray = Expect<Equal<K, string[]>>;
return keys;
}
// indexed access on a union distributes over each member
type Data = { type: "a"; value: string } | { type: "b"; value: number };
type ValueType = Data["value"];
type _valueDistributes = Expect<Equal<ValueType, string | number>>;
Open in TS Playground
flowchart TD
  concrete["T เป็น type เจาะจง เช่น User"] --> resolved["keyof T resolve ทันทีเป็น literal union"]
  generic["T เป็น bare generic type parameter"] --> deferred["keyof T ยังคงเป็น expression ที่ deferred ไม่โปร่งใส"]
  deferred --> callsite["resolve ก็ต่อเมื่อ function ถูกเรียกด้วย type argument จริงแล้วเท่านั้น"]
keyof T is deferred until T is a concrete type

มองว่า keyof T ข้างใน generic เหมือนตัวแปรที่ถูก capture อยู่ใน closure ที่ยังไม่ถูก evaluate: expression นั้นถูกต้องและมี type ครบ แต่ยังไม่มีอะไรเป็นรูปธรรมให้คำนวณ compiler เลือก defer มันแทนที่จะเดา ซึ่งเป็นเหตุผลเป๊ะที่ Object.keys(t) — การคำนวณ runtime ที่เกิดขึ้นจริง ๆ — type ให้ตรงกับสิ่งที่ยังไม่ resolve ไม่ได้

ทางเลือกข้อดีต้นทุน
Object.keys type เป็น string[]sound — ไม่เคยสัญญา key ที่ไม่ใช่ string จริงตอน runtime แม้ object จะมี property เพิ่มเกิน Tคุณเสียการเชื่อมกับ keyof T ไปเลย ต้อง cast หรือ validate เอาเองถ้าอยากได้ typed key กลับมา
cast Object.keys(t) as (keyof T)[]ได้ typed iteration สำหรับ case ที่คุณควบคุมทุก value ที่ assign เป็น Tนำ unsoundness แบบเดียวกับที่ signature จริงของ Object.keys ป้องกันไว้กลับมา — object ที่มี key เกินจะทำลายสัญญานั้นแบบเงียบ ๆ
indexed access บน union (Data["value"])อ่านเป็นธรรมชาติ ตรงกับพฤติกรรมของ property ที่ discriminated union แชร์กันโดยไม่ต้อง narrow ก่อนใช้ได้แค่กับ key ที่มีอยู่ในทุกmember ของ union — index key ที่มีแค่บาง member เป็น compile error ไม่ใช่ผลลัพธ์บางส่วน
  • keyof T ข้างใน generic function อ่านเป็น key จริงของ T ตัวไหนก็ตามที่จะกลายมาเป็นในตัว function body ตรงนั้นได้เลย ไม่จริง — keyof T ถูก defer จนกว่าจะ instantiate ข้างใน generic body มันทำตัวเป็น opaque type ไม่ใช่ literal union ที่ pattern-match ได้
  • Object.keys คืน string[] แทนที่จะเป็น (keyof T)[] เป็นข้อจำกัดที่ทีม TypeScript แค่ยังไม่ได้แก้ ไม่จริง มันเป็นทางเลือกเรื่อง soundness ที่ตั้งใจ: type รันไทม์ของ value มี key ได้มากกว่าที่ type แบบ static T ประกาศไว้ ดังนั้น (keyof T)[] จะเป็นสัญญาที่ compiler รักษาไว้ไม่ได้
  • index union ด้วย key ให้ value type จากแค่กิ่งเดียว กิ่งไหนก็ตามที่ “ชนะ” มันไม่ใช่แบบนั้น มัน distribute ผ่านทุก member ที่มี key นั้นแล้ว union value type ของทุกตัวเข้าด้วยกัน — ไม่มีตัวชนะ มีแค่ union
  • โค้ด generated ที่สมมติว่า Object.keys(obj) index กลับเข้า obj ได้ตรง ๆ (Object.keys(obj).forEach(k => console.log(obj[k]))) มักต้องมี cast, narrow ด้วย keyof typeof obj ที่ call site หรือยอมรับว่า value ที่อ่านแบบนี้ type เป็น union ของทุก property type — ไม่ใช่ lookup ที่เจาะจง การ type ตัวแปร loop k เป็น any แบบเงียบ ๆ เพื่อให้ error หายไปทำลายการป้องกันที่ signature จริงของ Object.keys มีไว้พอดี
  • generic helper ที่ cast Object.keys(t) as (keyof T)[] โดยไม่มี comment กำลังสัญญาว่า T ไม่มี key เกินตอน runtime — ควรเช็คว่าสัญญานั้นจริงไหมในทุก call site โดยเฉพาะตัวที่ส่ง subtype หรือ object literal ที่ assign ผ่านตัวแปรที่ type กว้างกว่าเข้ามา
  • Data["someKey"] ที่ใช้บน union ที่ someKey มีอยู่แค่บาง member เป็น compile error ไม่ใช่ผลลัพธ์แบบบางส่วนหรือแบบ undefined — ถ้าโค้ด generated ดูเหมือนคาดหวังแบบหลัง shape ของ union (หรือสมมติฐานของโค้ดเกี่ยวกับมัน) ควรถูกดูใหม่อีกที

🐛 หา bug ในโค้ดนี้

interface Config {
host: string;
port: number;
debug: boolean;
}
function logAllSettings<T extends object>(config: T) {
const keys: (keyof T)[] = Object.keys(config);
for (const key of keys) {
console.log(key, config[key]);
}
}
เฉลย

โค้ดนี้ compile ไม่ผ่าน — Object.keys(config) คือ string[] และ string[] assignable ให้ (keyof T)[] ไม่ได้ (เป็น TS2322 เป๊ะจาก section deferred-keyof ข้างบน) มันน่าลอง “แก้” ด้วย Object.keys(config) as (keyof T)[] แต่ cast นั้นคือ unsoundness เป๊ะที่ signature จริงหลีกเลี่ยงไว้: ถ้า logAllSettings ถูกเรียกด้วย value Config ที่จริง ๆ ถูกสร้างเป็น { ...defaultConfig, extra: "field" } แล้วส่งผ่าน type ที่กว้างกว่า keys จะมี "extra" รวมอยู่ด้วยแบบเงียบ ๆ ซึ่ง annotation (keyof T)[] สัญญาไว้ว่าจะไม่มีวันเกิดขึ้น ทางแก้ที่รักษาสัญญาไว้ได้คือไม่ต้องฝืน type จริง — วน Object.keys(config) เป็น string[] แล้วถ้าต้องการ index กลับเข้า config ให้ validate แต่ละ key เทียบกับ list ของ key ของ T ที่รู้จักตอน runtime หรือยอมรับ key parameter ที่เจาะจงและแคบกว่า (function logSettings<T, K extends keyof T>(config: T, keys: K[])) แทนที่จะพยายามกู้ keyof T กลับมาจากการสแกนตอน runtime

keyof User ให้อะไรออกมาสำหรับ interface User { id: number; name: string }?
indexed access type User["name"] ให้อะไร?
จะเอา element type ของ array type T = string[] อย่างไร?
User[keyof User] แทนอะไร?
keyof any คืออะไร?
อันนี้ compile ผ่านไหม และทำไม?
function describe<T extends object>(t: T) {
  const keys: (keyof T)[] = Object.keys(t);
  return keys;
}
V คืออะไร?
type Data = { type: "a"; value: string } | { type: "b"; value: number };
type V = Data["value"];