이펙티브 타입스크립트 2판 Day 11: 객체 타입은 열려.
Item 60–66 범위를 바탕으로 Object iteration, Record, tuple/rest, XOR, brand, @types 버전 경계를 코드 리뷰에서 다루는 방법을 정리합니다.
범위: Effective TypeScript 2판 Item 60–66
오늘의 질문: “객체 타입이 맞으면 객체 순회와 API prop 조합도 안전하다고 봐도 될까?”
type User = {
id: string;
name: string;
role: "admin" | "member";
};
function renderUser(user: User) {
return Object.keys(user).map((key) => user[key].toString());
}User의 키는 id | name | role처럼 보입니다. 그런데 Object.keys(user)의 타입은 보통 string[]입니다. 타입스크립트가 답답해서 그런 게 아니라, 자바스크립트 객체가 런타임에서 더 열려 있기 때문입니다. User 타입을 만족하는 값이 실제로는 추가 프로퍼티를 더 가질 수도 있습니다.
keyof User는 타입 공간의 키 집합입니다. Object.keys(user)는 런타임 값에서 열거 가능한 문자열 키를 읽습니다. 비슷해 보이지만 같은 계약이 아닙니다.
const user = {
id: "u1",
name: "Jin",
role: "admin",
debug: true,
} satisfies User;
Object.keys(user); // ["id", "name", "role", "debug"]이런 값은 User로 사용할 수 있지만 런타임 키에는 debug가 포함됩니다. 그래서 객체 순회 코드는 타입 단언으로 덮기 전에 질문해야 합니다.
이 순회는 “정해진 키 목록”을 돌고 싶은가, 아니면 실제 객체의 런타임 키를 전부 보고 싶은가?
정해진 키 목록이 필요하다면 명시적으로 닫는 편이 낫습니다.
const userFields = ["id", "name", "role"] as const;
type UserField = (typeof userFields)[number];
function renderUser(user: User) {
return userFields.map((field) => String(user[field]));
}Record는 “이 키 집합에 대해 값이 모두 있다”는 계약을 만들 때 유용합니다.
type Status = "idle" | "loading" | "success" | "error";
const labels: Record<Status, string> = {
idle: "대기",
loading: "불러오는 중",
success: "완료",
error: "실패",
};나중에 Status에 empty를 추가하면 labels가 빠진 키를 알려 줍니다. 이때 Record는 좋은 선택입니다.
반대로 키가 사용자가 만든 임의 문자열이고 계속 추가/삭제된다면 Record<string, V>보다 Map이나 명시적 자료구조가 더 솔직할 수 있습니다. 코드 리뷰에서는 키 공간이 닫혔는지부터 봅니다.
| 상황 | 더 잘 맞는 형태 | 리뷰 질문 |
|---|---|---|
| union 키 전체에 라벨 매핑 | Record | 새 union 값이 추가되면 빠진 매핑을 잡는가? |
| 동적 cache, id 기반 저장소 | Map 또는 별도 store | 추가/삭제/순회 순서가 중요한가? |
| 일부 키만 있을 수 있음 | Partial> | 없는 키를 어떻게 처리하는가? |
| 외부 JSON 객체 | runtime validation + 타입 | 실제 입력 검증이 있는가? |
배열 타입은 보통 “같은 종류의 값 여러 개”를 뜻합니다. tuple은 “몇 번째 자리에 무엇이 있는지”까지 계약으로 만듭니다.
type RouteMatch = [path: string, params: Record<string, string>];
function useRoute(): RouteMatch {
return ["/posts/:id", { id: "42" }];
}이런 경우 tuple label은 문서 역할을 합니다. 하지만 tuple이 길어지면 읽기 어렵습니다. 세 칸 이상이고 각 값의 의미가 독립적이라면 객체가 나을 수 있습니다.
rest parameter도 비슷합니다.
type LogArgs = [
level: "info" | "warn" | "error",
message: string,
...meta: unknown[],
];
function log(...args: LogArgs) {
const [level, message, ...meta] = args;
}리뷰 질문은 “위치가 이 API의 본질인가?”입니다. 위치가 본질이면 tuple이 좋고, 이름이 더 중요하면 객체가 좋습니다.
프론트엔드 컴포넌트에서는 둘 중 하나만 허용해야 하는 prop이 자주 나옵니다.
type LinkButtonProps =
| { href: string; onClick?: never }
| { href?: never; onClick: () => void };이 타입은 링크 버튼과 클릭 버튼을 동시에 받는 실수를 막습니다.
// 가능
const link: LinkButtonProps = { href: "/settings" };
// 실패해야 함
const broken: LinkButtonProps = { href: "/settings", onClick: () => {} };여기서는 타입 기술 자체보다 “잘못된 조합을 public API에서 막을 가치가 있는가?”를 봅니다. 모든 prop 조합을 복잡한 타입으로 막으려 하면 API가 어려워집니다. 사용자가 자주 틀리고 런타임 버그로 이어지는 조합부터 막는 편이 좋습니다.
구조적 타입 시스템에서는 모양이 같으면 호환됩니다.
type UserId = string;
type PostId = string;
function loadUser(id: UserId) {}
const postId: PostId = "p1";
loadUser(postId); // 통과둘 다 string이라서 구분되지 않습니다. 의미가 섞이면 위험한 경계에서는 brand를 붙일 수 있습니다.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;brand는 런타임 검증이 아닙니다. 값이 정말 UserId인지 확인하는 함수나 생성 경계가 있어야 합니다. 리뷰에서는 brand 자체보다 “이 branded 값은 어디서 만들어지고 검증되는가?”를 봐야 합니다.
라이브러리 타입은 코드와 따로 움직일 수 있습니다. 특히 런타임 패키지와 @types/* 패키지가 분리되어 있으면 버전 어긋남이 버그처럼 보일 수 있습니다.
{
"dependencies": {
"some-lib": "2.4.0"
},
"devDependencies": {
"@types/some-lib": "1.9.0"
}
}이 상태에서 타입 에러가 난다면 내 코드가 틀린 게 아니라 타입 패키지가 오래됐을 수도 있습니다. 반대로 타입이 통과해도 런타임 라이브러리와 맞지 않을 수 있습니다.
코드 리뷰에서는 dependency 변경 PR에서 타입 패키지도 함께 봅니다.
런타임 패키지와 @types 버전이 맞는가?
라이브러리가 자체 타입을 제공하는데 불필요한 @types가 남아 있지 않은가?
타입 에러를 단언으로 덮기 전에 버전 불일치를 확인했는가?
lockfile 변경이 실제 의존성 변화를 설명하는가?
| 변화 | 확인할 질문 |
|---|---|
Object.keys 순회 | 런타임 키 전체를 도는가, 닫힌 키 목록을 도는가? |
Record 추가 | 키 공간이 union처럼 닫혀 있는가? 없는 키 처리는 명확한가? |
| tuple/rest 타입 | 위치가 계약인가, 이름 있는 객체가 더 읽기 쉬운가? |
| XOR prop 타입 | 실제로 자주 틀리는 조합을 막는가, 타입만 과하게 복잡한가? |
| brand 타입 | branded 값의 생성/검증 경계가 있는가? |
@types 변경 | 런타임 패키지 버전과 타입 패키지 버전이 맞는가? |
이번 범위의 핵심은 타입 도구를 많이 쓰는 것이 아닙니다. 객체 타입은 자바스크립트 런타임의 열린 성질 위에 놓입니다. 그래서 좋은 타입 설계는 “무엇을 허용할지”뿐 아니라 “어떤 잘못된 조합을 어디서 막을지”를 코드 리뷰에서 말할 수 있어야 합니다.
Post Q&A
이펙티브 타입스크립트 2판 Day 11: 객체 타입은 열려 있고, 계약은 닫아야 한다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
Item 55–59 범위를 바탕으로 type test, 타입 표시, 꼬리 재귀 한계, 코드 생성, never exhaustiveness를 코드 리뷰에서 다루는 방법을 정리합니다.