TypeScript의 keyof, typeof, 그리고 indexed access types
1. 개요
코드베이스가 커질수록, 같은 키 집합(예: 주문 상태)이 여러 곳에 나타나는 상황을 마주하게 됩니다: object, union type, 함수 매개변수 등. 한 곳만 수정하고 다른 곳을 깜빡하면 버그가 생깁니다.
TypeScript는 한 번만 선언한 후 나머지는 추론하도록 해주는 세 가지 도구가 있습니다. keyof는 type의 key 집합을 가져옵니다. typeof는 값에서 type을 가져옵니다. Indexed access type (T["key"])은 속성의 type을 가져옵니다.
이 글을 읽은 후, 이 세 가지 도구를 결합하여 안전한 generic 함수를 작성하고 타입이 엄격한 event bus를 만드는 방법을 알게 될 것입니다. 이들을 사용할 때 흔히 발생하는 오류들도 알게 될 겁니다.
2. 준비
이 글은 TypeScript 5.x와 Vitest를 사용하여 테스트를 작성합니다. 다음과 같이 설치하세요:
npm install --save-dev typescript vitest
tsconfig.json에서 strict를 활성화해야 합니다. 활성화하지 않으면 undefined 관련 많은 오류가 보고되지 않습니다:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. keyof 연산자
3.1. 기본 사용법
keyof T는 T의 속성 이름으로 이루어진 union을 반환합니다:
type Order = {
id: string;
total: number;
status: "pending" | "paid" | "cancelled";
note?: string;
};
type OrderKey = keyof Order;
// "id" | "total" | "status" | "note"
const sortBy: OrderKey = "total"; // OK
const wrong: OrderKey = "price"; // 오류
마지막 줄은 컴파일되지 않습니다:
error TS2322: Type '"price"'은(는) type 'keyof Order'에 할당할 수 없습니다.
union "id" | "total" | ...을 수동으로 입력할 필요가 없습니다. Order에 새 속성을 추가하면 OrderKey는 자동으로 업데이트됩니다.
3.2. Index signature와 keyof
type이 index signature를 사용하면 결과가 예상과 다를 수 있습니다:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey는 string만 아니라 string | number입니다. 이유는 JavaScript에서 obj[1]과 obj["1"]이 같은 속성에 접근하기 때문입니다. 따라서 TypeScript는 number 타입의 key도 허용합니다.
3.3. Generic에서의 keyof
keyof는 generic constraint와 함께 사용할 때 가장 효과적입니다:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
const total = getProperty(order, "total"); // number
const status = getProperty(order, "status"); // "pending" | "paid" | "cancelled"
getProperty(order, "price"); // 컴파일 오류
이 함수는 다음을 수행합니다:
K extends keyof T는key를 제한하므로,obj의 실제 속성 이름만 받습니다.- 반환 타입
T[K]는 indexed access type으로, 섹션 5에서 설명합니다. 덕분에 함수를 호출할 때마다 해당 속성의 정확한 타입을 얻습니다. "total"로 호출하면number를 얻습니다."status"로 호출하면 union literal을 얻습니다. 여기에는any가 없습니다.
4. Type 컨텍스트의 typeof 연산자
JavaScript의 typeof는 runtime에 실행되어 "string" 또는 "object" 같은 문자열을 반환합니다. TypeScript는 두 번째 의미를 추가합니다: type 위치에 놓일 때, typeof x는 변수 x의 type을 줍니다.
4.1. 값에서 type 가져오기
const defaultCustomer = {
name: "Nguyễn Văn An",
email: "an@example.com",
loyaltyPoints: 0,
};
type Customer = typeof defaultCustomer;
// { name: string; email: string; loyaltyPoints: number }
function createCustomer(input: Partial<Customer>): Customer {
return { ...defaultCustomer, ...input };
}
console.log(createCustomer({ name: "Trần Thị Bình" }));
결과:
{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }
여기서 기본값이 유일한 출처입니다. defaultCustomer에 필드를 추가하면 Customer도 그 필드를 갖습니다.
함수의 경우, typeof를 내장 utility type과 결합합니다:
function loadConfig() {
return { apiUrl: "https://api.shop.local", timeoutMs: 5000, retries: 3 };
}
type AppConfig = ReturnType<typeof loadConfig>;
// { apiUrl: string; timeoutMs: number; retries: number }
4.2. as const와 typeof
기본적으로 TypeScript는 literal을 “확대”합니다: "pending"을 string으로, 배열을 string[]로. as const를 추가하면 literal을 유지하고 readonly로 표시합니다:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
as const가 없으면 typeof ORDER_STATUSES는 string[]일 뿐이고 모든 literal 정보를 잃습니다.
4.3. keyof typeof 결합하기
이것은 상수 object를 가지고 있고 key의 union을 원할 때 매우 일반적인 방식입니다:
const SHIPPING_FEES = {
standard: 30_000,
express: 60_000,
sameDay: 120_000,
} as const;
type ShippingMethod = keyof typeof SHIPPING_FEES;
// "standard" | "express" | "sameDay"
function shippingFee(method: ShippingMethod): number {
return SHIPPING_FEES[method];
}
console.log(shippingFee("express"));
60000
읽기는 안에서 밖으로입니다. typeof SHIPPING_FEES는 object의 type을 가져오고, 그 다음 keyof는 그 type의 key를 가져옵니다. keyof SHIPPING_FEES라고 쓸 수 없습니다. SHIPPING_FEES는 값이지 type이 아니기 때문입니다.
5. Indexed access types
5.1. 속성의 type 가져오기
T["key"] 구문은 속성 접근과 비슷하지만 type 레벨에서 발생합니다:
type OrderStatus = Order["status"]; // "pending" | "paid" | "cancelled"
type OrderNote = Order["note"]; // string | undefined
type IdOrTotal = Order["id" | "total"]; // string | number
type AnyOrderValue = Order[keyof Order]; // object의 모든 속성 type의 union
주의할 점이 세 가지 있습니다:
- optional 속성 (
note?)은undefined를 포함한 type을 반환합니다. - union key를 전달하면 해당하는 type들의 union을 받습니다.
T[keyof T]는 object의 “모든 값의 type”을 가져오는 일반적인 방법입니다.
5.2. 배열 요소의 type 가져오기
number로 index하면 요소의 type을 얻습니다:
type OrderStatusFromList = (typeof ORDER_STATUSES)[number];
// "pending" | "paid" | "shipped" | "cancelled"
function isOrderStatus(value: string): value is OrderStatusFromList {
return (ORDER_STATUSES as readonly string[]).includes(value);
}
console.log(isOrderStatus("paid"), isOrderStatus("refunded"));
true false
배열 ORDER_STATUSES는 이제 두 가지를 수행합니다. runtime에는 API에서 input을 검증하기 위한 목록입니다. 컴파일 시에는 union type을 생성합니다. 상태를 한 번만 선언하면 됩니다.
5.3. 중첩 접근
깊은 구조에 접근하기 위해 여러 번 index를 연결할 수 있습니다:
type Invoice = {
customer: { name: string; address: { city: string; zip: string } };
lines: Array<{ sku: string; quantity: number; unitPrice: number }>;
};
type City = Invoice["customer"]["address"]["city"]; // string
type InvoiceLine = Invoice["lines"][number];
// { sku: string; quantity: number; unitPrice: number }
function lineTotal(line: InvoiceLine): number {
return line.quantity * line.unitPrice;
}
InvoiceLine은 Invoice에서 직접 가져오므로, 별도의 type을 선언하고 두 곳을 동기화할 필요가 없습니다.
6. 실제 예제: 타입이 엄격한 event bus
이제 세 도구를 모두 합칩시다. 목표는 각 event의 payload가 올바르게 type 검사되는 event bus입니다:
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; method: "card" | "cash" };
cancelled: { orderId: string; reason: string };
};
type Handler<K extends keyof OrderEvents> = (payload: OrderEvents[K]) => void;
export class OrderEventBus {
private handlers: { [K in keyof OrderEvents]?: Handler<K>[] } = {};
on<K extends keyof OrderEvents>(event: K, handler: Handler<K>): void {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
this.handlers[event]?.forEach((handle) => handle(payload));
}
}
const bus = new OrderEventBus();
bus.on("paid", (p) => console.log(`Đơn ${p.orderId} thanh toán bằng ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // 오류: 'method' 누락
// bus.emit("refunded", { orderId: "ORD-42" }); // 오류: event 없음
출력:
Đơn ORD-42 thanh toán bằng card
각 부분이 다음과 같이 작동합니다:
keyof OrderEvents는 event 이름을 세 값"created" | "paid" | "cancelled"로 제한합니다.OrderEvents[K]는 payload를 올바른 event와 연결합니다."paid"의 handler에서 매개변수p는method를 가지지만reason은 없습니다.- mapped type
[K in keyof OrderEvents]는 handler 저장소를 만들고, 각 event는 자신만의 목록을 가집니다. refundedevent를 추가하려면OrderEvents만 수정하면 됩니다. 나머지는 자동으로 업데이트됩니다.
7. Vitest로 type 테스트하기
Vitest는 type을 검사하기 위해 expectTypeOf를 가지고 있습니다. @ts-expect-error와 결합하여 잘못된 코드가 차단되도록 하세요:
import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { OrderEventBus } from "./order-event-bus";
type Order = { id: string; total: number; status: "pending" | "paid" };
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
describe("keyof와 indexed access", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("올바른 값과 올바른 type 반환", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("존재하지 않는 key 거부", () => {
// @ts-expect-error - 'price'는 Order의 key가 아님
getProperty(order, "price");
});
it("event bus가 올바른 payload로 handler 호출", () => {
const bus = new OrderEventBus();
const handler = vi.fn();
bus.on("created", handler);
bus.emit("created", { orderId: "ORD-7", total: 99_000 });
expect(handler).toHaveBeenCalledWith({ orderId: "ORD-7", total: 99_000 });
});
});
다음 두 명령으로 테스트를 실행합니다:
npx vitest run --typecheck
npx tsc --noEmit
주의:
expectTypeOf는--typecheck또는tsc를 통해 type이 검사될 때만 효과가 있습니다. runtime에는 거의 아무 작업도 하지 않습니다. 일반적으로vitest run만 실행하면 type이 잘못되어도 test가 pass합니다.
누군가 오류 목록에서 "price"를 제거하는 대신 Order에 price를 추가하면, @ts-expect-error는 “Unused ‘@ts-expect-error’ directive”를 보고합니다. 이는 좋은 신호입니다: 당신의 type 테스트가 API 계약을 보호하고 있습니다.
8. 흔한 오류
변수를 index type으로 사용하기. const key = "total"에서 Order[key]를 쓰면 오류가 발생합니다:
error TS2749: 'key'는 값을 나타내지만 여기서 type으로 사용되고 있습니다. 'typeof key'를 의도하셨나요?
해결하려면 Order[typeof key]를 작성하세요.
표현식에 typeof 호출하기. typeof loadConfig()는 구문 오류입니다. typeof는 identifier 또는 typeof config.db 같은 속성 접근만 허용합니다. 함수 결과의 경우 ReturnType<typeof loadConfig>를 사용합니다.
Object.keys가 keyof T를 반환한다고 생각하기. 실제로는 string[]을 반환합니다:
for (const key of Object.keys(order)) {
// order[key]; // 오류: 'string'으로는 'Order'를 index할 수 없음
}
TypeScript가 이렇게 하는 것은 의도적입니다. Type은 “구조적”이므로, runtime에 object는 type이 선언한 것보다 더 많은 속성을 포함할 수 있습니다. 데이터에 확신이 있으면 Object.keys(order) as (keyof Order)[]로 type을 강제할 수 있습니다. 하지만 그것은 컴파일러를 대신하여 약속하는 것임을 이해하세요.
as const 잊기. 없으면 (typeof ORDER_STATUSES)[number]은 string일 뿐이고 union literal의 모든 이점이 오류 없이 사라집니다.
주의: 남용하지 마세요.
Invoice["customer"]["address"]["city"]같은 체인은 type이 실제로Invoice에 종속될 때 합리적입니다.Address가 독립적인 개념이고 여러 곳에서 사용된다면, 이름이 있는 type으로 선언하세요. 코드가 더 읽기 쉽고 오류 메시지도 더 간결합니다.
9. 결론
이 글의 세 도구는 세 가지 다른 작업을 합니다:
keyof는 type의 key 집합을 가져옵니다.K extends keyof Tconstraint를 만들 때 가장 유용합니다.typeof는 type 위치에서 값을 type으로 변환합니다.as const와 함께 사용하면 배열과 상수 object를 유일한 출처로 만드는 데 도움이 됩니다.- indexed access type
T[K]는 속성, 배열 요소 ([number]), 또는 중첩된 구조의 type을 가져옵니다.
모두 합치면, 컴파일러가 잘못된 key나 잘못된 payload를 컴파일 시에 잡는 generic 함수와 event bus를 작성할 수 있습니다. 또한 여러 곳에서 같은 목록을 반복할 필요가 없습니다. strict를 활성화하고, CI에서 tsc 또는 vitest --typecheck를 실행하며, type이 실제로 서로 종속될 때만 긴 index 체인을 사용하는 것을 기억하세요.