반응형

TypeScript를 쓰다 보면 keyof, T[K], [P in K] 같은 문법을 자주 만나게 됩니다. 처음에는 각각 따로 외워야 하는 문법처럼 보이지만, 실제로는 하나의 흐름으로 연결되어 있습니다.

핵심은 간단합니다. 객체 타입에서 키를 타입으로 꺼내고, 그 키에 해당하는 값의 타입을 다시 조회한 뒤, 필요한 형태의 새 객체 타입을 만드는 것입니다.

keyof는 객체 타입의 키를 유니온으로 만든다

먼저 keyof T는 객체 타입 T가 가진 키들을 유니온 타입으로 만들어줍니다.

type User = {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
};

type UserKey = keyof User;
// "id" | "name" | "email" | "isAdmin"

이제 UserKey 타입에는 "id", "name", "email", "isAdmin" 중 하나만 들어갈 수 있습니다. 아무 문자열이나 허용하지 않고, 실제 User 타입에 존재하는 키만 허용하는 것이 중요합니다.

const userKey: UserKey = "email";

이렇게 하면 키 이름을 문자열로 다루더라도 TypeScript가 안전하게 검사할 수 있습니다.

T[K]는 특정 키의 값 타입을 꺼낸다

T[K]는 객체 값을 실제로 읽는 문법이 아닙니다. 타입 시스템 안에서 “이 키가 가리키는 값의 타입이 무엇인가?”를 꺼내는 문법입니다.

type UserName = User["name"];
// string

type UserPrimitiveField = User["id" | "name" | "email" | "isAdmin"];
// number | string | boolean

User["name"]은 User 타입에서 name 필드의 타입인 string을 꺼냅니다. 여러 키를 넣으면 각 키의 값 타입을 모아 유니온으로 만듭니다.

이 문법을 잘 이해하면 타입을 손으로 다시 쓰지 않고, 기존 타입에서 필요한 정보만 정확히 가져올 수 있습니다.

K extends keyof T가 필요한 이유

제네릭으로 타입을 만들 때는 K가 아무 키나 될 수 없도록 제한해야 합니다.

function getValue<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key];
}

여기서 K extends keyof T는 K가 반드시 T의 실제 키 중 하나여야 한다는 뜻입니다. 이 제약이 있어야 T[K]를 안전하게 사용할 수 있습니다.

const userEmail = getValue(user, "email");
// string

예를 들어 User 객체에서 "email"을 꺼내면 TypeScript는 반환 타입을 string으로 추론합니다. 반대로 존재하지 않는 키를 넣으면 컴파일 단계에서 막아줍니다.

매핑 타입은 키를 순회해 새 객체 타입을 만든다

매핑 타입은 키 유니온을 하나씩 순회하면서 새로운 객체 타입을 만듭니다.

type PickFields<T, K extends keyof T> = {
  [P in K]: T[P];
};

이 코드는 TypeScript 내장 유틸리티 타입인 Pick<T, K>의 핵심 구조와 거의 같습니다.

  • K extends keyof T: 고를 수 있는 키를 원본 타입의 키로 제한
  • [P in K]: 선택된 키들을 하나씩 순회
  • T[P]: 원본 타입에서 해당 키의 값 타입을 가져옴

즉, PickFields<User, "id" | "name">은 User 타입에서 id와 name만 골라 새 타입을 만듭니다.

type UserSummary = PickFields<User, "id" | "name">;

const summary: UserSummary = {
  id: 1,
  name: "Kim",
};

실무에서는 파생 타입을 만드는 데 유용하다

실무에서는 API 응답, DTO, 폼 값, 컴포넌트 props처럼 객체 타입의 일부만 사용해야 하는 일이 많습니다. 이때 비슷한 타입을 매번 손으로 다시 작성하면 원본 타입이 바뀌었을 때 문제가 생깁니다.

예를 들어 사용자 정보 중 수정 가능한 필드만 따로 payload 타입으로 만들고 싶다면 다음처럼 작성할 수 있습니다.

type EditableUserProfile =
  Partial<PickFields<User, "name" | "email" | "profile">>;

이 타입은 User에서 name, email, profile만 고른 뒤, 각 필드를 선택 입력으로 만듭니다. 원본 User 타입과 연결되어 있기 때문에 필드 타입이 바뀌어도 파생 타입이 함께 따라갑니다.

너무 복잡한 타입은 오히려 읽기 어렵다

keyof, T[K], 매핑 타입은 강력하지만 과하게 중첩하면 읽기 어려운 타입이 됩니다. 타입 안정성만큼 중요한 것은 코드 리뷰에서 의도가 바로 드러나는 것입니다.

  • 원본 타입에서 파생 가능한 타입은 Pick, Omit, Partial 등을 활용한다.
  • 반복되는 타입 선언은 유틸리티 타입으로 줄인다.
  • 너무 긴 타입 조합은 별도 타입 이름을 붙여 의도를 드러낸다.
  • 읽는 사람이 이해하기 어려울 정도로 중첩하지 않는다.

정리

keyof, T[K], 매핑 타입은 따로 외우는 문법이 아니라 하나의 흐름으로 이해하는 것이 좋습니다.

keyof는 객체 타입의 키를 타입으로 꺼내고, T[K]는 해당 키의 값 타입을 조회합니다. 매핑 타입은 이 둘을 조합해 새로운 객체 타입을 만듭니다.

이 흐름을 이해하면 TypeScript에서 중복 타입을 줄이고, 원본 타입과 연결된 안전한 파생 타입을 만들 수 있습니다. 특히 폼 데이터, API payload, 컴포넌트 props처럼 객체 타입을 자주 다루는 프론트엔드 코드에서 큰 도움이 됩니다.

반응형