본문 바로가기

인덱스 타입

인덱스 시그니처로 이름을 미리 정할 수 없는 키의 값 타입을 정하는 방법과 그 제약, 인덱스 접근 타입 T[K]로 속성의 타입을 꺼내는 방법을 다루고, 열린 키로 조회한 값이 실제로는 없을 수 있다는 점을 예제로 확인합니다.

객체를 다루는 타입에는 두 방향의 질문이 있습니다. 하나는 "이 객체에 어떤 키가 들어올 수 있고, 그 값은 무엇인가"이고, 다른 하나는 "이미 있는 타입에서 이 키의 값은 어떤 타입인가"입니다. 앞의 질문에는 인덱스 시그니처(index signature)가, 뒤의 질문에는 인덱스 접근 타입(indexed access type)이 답합니다.

2장과 3장에서 두 기능을 잠깐씩 썼는데, 이 글은 둘을 모아 규칙을 정리합니다. 인덱스 시그니처를 쓸 때 지켜야 할 제약과, 열어 둔 키로 조회한 값이 실제로는 없을 수 있다는 점을 함께 확인합니다. 예제는 ts-first 프로젝트에 index-types.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

인덱스 시그니처

인덱스 시그니처는 [key: 키의 타입]: 값의 타입으로 적으며, 속성의 이름을 하나씩 나열하는 대신 "이런 종류의 키라면 몇 개든 있을 수 있고, 값은 이 타입"이라고 정합니다. 키의 타입으로는 string과 number, symbol, 템플릿 리터럴 패턴과 이들의 유니온을 쓸 수 있습니다.

interface StringNumberMap {
  [key: string]: number;
}

const scores: StringNumberMap = {
  math: 90,
  english: 85,
};
scores["science"] = 92;

console.log(scores["math"], scores.english, Object.keys(scores).length);
90 85 3

키가 문자열이면 무엇이든 넣을 수 있으므로, 처음 객체에 없던 science도 나중에 더할 수 있습니다. 대신 값은 모두 숫자여야 하므로, scores["korean"] = "bad";는 Type 'string' is not assignable to type 'number'.로 진단됩니다.

[index: number]: 값의 타입처럼 숫자 키를 쓰면 배열과 비슷한 객체를 나타냅니다. 자바스크립트에서 객체의 숫자 키는 실제로는 문자열 "0", "1"로 저장됩니다. 그래서 interface BooleanArrayLike { [index: number]: boolean } 타입의 객체 checks는 checks[2]뿐 아니라 checks["2"]처럼 숫자 모양의 문자열로도 조회할 수 있습니다. 같은 이유로, 한 타입에 숫자 인덱스와 문자열 인덱스를 함께 두면 숫자 인덱스의 값 타입이 문자열 인덱스의 값 타입에 할당될 수 있어야 합니다.

인덱스 시그니처를 두면, 그 키의 범위에 드는 다른 속성도 같은 값의 규칙을 지켜야 합니다. 문자열 인덱스 시그니처는 모든 문자열 키를 덮으므로, 이름을 따로 적은 속성의 값도 그 타입이어야 합니다.

interface UserProfile {
  name: string;
  [key: string]: string;
}

const profile: UserProfile = { name: "Alice", city: "Seoul" };
console.log(profile.name, profile.city);
Alice Seoul

name도 문자열이고 인덱스 시그니처의 값도 문자열이므로 함께 둘 수 있으며, city는 인덱스 시그니처 덕분에 허용됩니다. 그런데 id: number를 같은 인터페이스에 더하면 Property 'id' of type 'number' is not assignable to 'string' index type 'string'.이라는 TS2411이 나옵니다. 두 값을 함께 두려면 인덱스 시그니처의 값을 string | number로 넓혀야 하는데, 그러면 다른 모든 키의 값도 함께 넓어집니다.

열린 키는 값의 존재를 보장하지 않는다

인덱스 시그니처는 어떤 키든 받을 수 있다는 규칙이지, 그 키의 속성이 실제로 있다는 약속이 아닙니다. 없는 키를 조회하면 자바스크립트는 undefined를 돌려주는데, 기본 설정에서 그 값의 타입은 여전히 값의 타입으로 보입니다.

const openScores: Record<string, number> = { math: 90 };
const missingScore = openScores["history"];
console.log(missingScore);
undefined

Record<string, number>는 [key: string]: number와 같은 열린 사전이어서, missingScore의 타입은 number이지만 실제 값은 undefined입니다. 2장에서 본 noUncheckedIndexedAccess를 켜면 이 조회의 타입에 undefined가 더해져 확인 없이 숫자처럼 쓰는 코드를 진단하지만, 실행 결과는 옵션과 관계없이 같습니다.

열린 인덱스 시그니처의 값 규칙과 실제 속성 존재를 분리한다. noUncheckedIndexedAccess는 선언되지 않은 키 조회 결과에 undefined를 포함한다.

TYPE / RUNTIME / CONTRACT

열린 키 규칙은 실제 속성의 존재를 보장하지 않는다

string 인덱스 시그니처는 임의의 문자열 키에 대한 값 타입을 정합니다. 본문의 없는 키 조회는 실행 시 undefined입니다.

열린 키의 조회 결과를 확인해 값을 정한다openScores에서history조회→실제undefined→undefined인지검사→업무기본값0또는존재하는점수사용의5노드4간선이다. 인덱스시그니처가키를생성하지않는다. noUncheckedIndexedAccess는정적반환타입에undefined를보이며런타임검사는실제함수코드가수행한다.점수 사전 조회scores[subject]실제 반환값history → undefinedundefined 검사score === undefined참 · 기본값누락 과목 → 0거짓 · 원래 값실제 0점도 보존참거짓
열린 키의 조회 결과를 확인해 값을 정한다 · 모바일openScores에서history조회→실제undefined→undefined인지검사→업무기본값0또는존재하는점수사용의5노드4간선이다. 인덱스시그니처가키를생성하지않는다. noUncheckedIndexedAccess는정적반환타입에undefined를보이며런타임검사는실제함수코드가수행한다. 모바일에서도 동일 노드와 간선 방향을 유지한다.점수 사전 조회scores[subject]실제 반환값history → undefinedundefined 검사score === undefined참 · 기본값누락 과목 → 0거짓 · 원래 값실제 0점도 보존참거짓 · 바깥 가지

OPEN KEYS

점수 사전의 값 규칙

StringNumberMap에서 math는 90입니다. 문자열 키의 값은 number로 제한하지만 nonexistent라는 속성이 생성되는 것은 아닙니다.

EXPLICIT PROPERTY

명시적 속성도 규칙에 맞는다

[key: string]: string인 UserProfile에 name:string은 맞지만 id:number는 충돌합니다. 같은 객체의 이름 있는 속성에도 인덱스 규칙이 적용됩니다.

NUMERIC KEYS

숫자 키와 문자열 키

BooleanArrayLike의 2와 "2"는 같은 속성 키입니다. number와 string 시그니처를 함께 쓰면 숫자 인덱스 값 타입이 문자열 인덱스 값 타입에 할당 가능해야 합니다.

OPTION / RUNTIME

없는 값까지 타입에 드러낸다

noUncheckedIndexedAccess를 켜면 선언되지 않은 키 조회에 undefined가 포함됩니다. 옵션은 속성을 만들거나 존재 검사를 실행하지 않습니다.

AppConfig의 Record<string, string | number | boolean>도 열린 키 타입입니다. 필요한 값인지 실행 중 확인한 뒤 사용하세요.

그래서 열린 사전에서 값을 꺼낼 때는, 위 그림처럼 조회한 값이 undefined인지 직접 확인한 뒤 쓸 값을 정합니다. 다음 함수는 없는 과목을 0점으로 처리하는 규칙을 코드로 옮긴 것입니다.

function scoreOrZero(scoreBook: Record<string, number>, subject: string): number {
  const score = scoreBook[subject];
  return score === undefined ? 0 : score;
}

console.log(scoreOrZero({ math: 0 }, "math"));
console.log(scoreOrZero({ math: 90 }, "history"));
0
0

첫 호출은 실제로 0점인 과목이고, 둘째 호출은 없는 과목이어서 기본값 0이 쓰였습니다. 0점도 올바른 점수이므로 score ? score : 0처럼 참거짓으로 검사하지 않고, undefined인지를 정확히 비교했습니다. 업무에서 "없음"과 0점을 구분해야 한다면 둘을 합치지 말고, number | undefined를 돌려주어 호출한 쪽이 정하게 합니다.

열린 사전과 달리, 키가 정해진 유니온 Record<"math" | "english", number>는 두 키를 모두 요구하는 닫힌 객체 타입입니다. 이 타입의 변수에 { math: 90 }만 넣으면 english가 빠졌다는 진단이 나오므로, 키의 목록이 정해져 있다면 열린 사전보다 이쪽이 더 많은 실수를 잡습니다.

더 깊이: 열린 키를 다루는 옵션과 키 타입의 제한

noUncheckedIndexedAccess 말고도 열린 키를 조심스럽게 다루게 하는 옵션이 있습니다. 기본 설정에서는 인덱스 시그니처가 있는 타입이면 선언하지 않은 키도 settings.username처럼 점 표기로 읽을 수 있습니다. noPropertyAccessFromIndexSignature를 켜면 이런 읽기가 TS4111로 진단되고, 인덱스 시그니처에서 온 키는 settings["username"]처럼 대괄호로 읽어야 합니다. TSConfig 레퍼런스는 이 옵션의 목적을, 그 속성이 있다고 얼마나 확신하는지를 호출하는 문법으로 드러내는 것이라고 설명합니다.

인덱스 시그니처의 키에 쓸 수 있는 타입은 정해져 있습니다. TypeScript 4.4 릴리스 노트에 따르면 string, number, symbol과 `data-${string}` 같은 템플릿 문자열 패턴처럼 값이 끝없이 많은 타입과 그 유니온만 쓸 수 있고, 유니온으로 적은 시그니처는 키 타입마다 하나씩인 여러 시그니처로 풀립니다. "math" | "english"처럼 값이 정해진 리터럴의 유니온은 인덱스 시그니처의 키가 될 수 없으므로, 그런 키에는 매핑된 타입인 Record를 써서 닫힌 객체 타입을 만듭니다. 같은 릴리스 노트는 이런 사전에는 일반 객체보다 Map<string, boolean> 같은 Map이 더 나은 자료 구조일 수 있다고도 적습니다.

인덱스 접근 타입

인덱스 접근 타입 T[K]는 타입 T에서 키 K의 속성 타입을 꺼냅니다. 자바스크립트에서 obj[key]로 속성의 값을 읽듯이, 타입에서는 Type[Key]로 속성의 타입을 읽는 것입니다.

interface UserData {
  id: number;
  name: string;
  email: string;
  address: {
    street: string;
    city: string;
  };
}

type UserId = UserData["id"];
type UserCity = UserData["address"]["city"];
type ContactInfo = UserData["email" | "name"];
type AnyUserValue = UserData[keyof UserData];
타입결과
UserData["id"]number
UserData["address"]["city"]string
UserData["email" | "name"]string
UserData[keyof UserData]string | number | { street: string; city: string; }

중첩된 객체는 ["address"]["city"]처럼 이어서 꺼낼 수 있고, 키 자리에 유니온을 넣으면 각 키의 타입을 모은 유니온이 나옵니다. email과 name은 둘 다 string이므로 합쳐서 string 하나가 되고, keyof UserData를 넣으면 모든 속성 타입의 유니온이 됩니다. 그래서 const anyValue: AnyUserValue = true;는 불리언이 이 유니온에 없어 진단됩니다.

인덱스 시그니처는 열린 키, 유한 리터럴 Record는 필수 키, keyof는 기존 타입의 키, T[K]는 선택한 값 타입을 나타낸다.

TYPE / RUNTIME / CONTRACT

허용할 키와 그 키의 값 타입을 구분한다

열린 사전, 유한한 키 집합, 기존 타입의 속성 조회는 목적이 다릅니다. T[K]는 선택한 키들의 값 타입을 가져옵니다.

OPEN

임의의 키에 같은 값 규칙

AppConfig의 Record<string, V>는 열린 사전입니다. 존재하지 않는 키도 조회할 수 있으며 실제 결과는 undefined일 수 있습니다.

FINITE · 다음 절 비교

정해진 키를 모두 요구

Record<"home" | "about" | "contact", V>는 세 키를 모두 요구합니다. 유한 리터럴 키와 string 전체를 구분하세요.

INDEXED ACCESS

UserData의 값 타입

UserData["id"]는 number, ["address"]["city"]는 string입니다. ["name" | "email"]은 같은 string 타입으로 합쳐집니다.

GENERIC LOOKUP

키와 반환 타입을 연결

getProperty(user, "name")은 "Charlie", "id"는 1입니다. K extends keyof T가 "address" 같은 없는 키를 제한하고 T[K]가 반환 타입을 연결합니다.

keyof와 T[K]는 타입 수준 연산입니다. 실행 중 값을 얻는 것은 user[key] 표현식입니다.

위 그림은 이 글의 세 가지 키를 나란히 비교합니다. 인덱스 시그니처와 Record<string, V>는 어떤 키든 받는 열린 키를, 키가 정해진 Record는 모두 있어야 하는 닫힌 키를, keyof와 T[K]는 이미 있는 타입의 키와 그 값의 타입을 다룹니다. 인덱스 시그니처가 있는 타입의 keyof는 그 키의 타입이 되는데, 문자열 인덱스 시그니처의 keyof는 숫자 키도 문자열로 조회할 수 있으므로 string | number입니다.

3장에서 만든 getProperty가 바로 이 두 기능을 함께 쓴 함수입니다.

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

const myUser = { id: 1, name: "Charlie", age: 40 };
console.log(getProperty(myUser, "name"), getProperty(myUser, "age"));
Charlie 40

K extends keyof T는 넘길 수 있는 키를 myUser에 실제로 있는 "id" | "name" | "age"로 제한하고, 반환 타입 T[K]는 고른 키의 속성 타입을 결과로 돌려줍니다. 열린 사전과 달리 여기서는 키가 정해진 타입이므로, 없는 키를 넘기면 실행 전에 진단됩니다.

정리

  • 인덱스 시그니처 [key: string]: V는 이름을 미리 정할 수 없는 키의 값 타입을 정하며, 그 범위에 드는 다른 속성의 값도 같은 규칙을 지켜야 합니다.
  • 숫자 키는 실제로 문자열로 저장되므로, 숫자 인덱스의 값 타입은 함께 둔 문자열 인덱스의 값 타입에 할당될 수 있어야 합니다.
  • 열린 사전은 키의 존재를 보장하지 않으므로, 조회한 값이 undefined인지 확인하고, noUncheckedIndexedAccess를 켜면 그 가능성이 타입에 드러납니다.
  • 키가 정해진 유니온의 Record는 모든 키를 요구하는 닫힌 객체 타입입니다.
  • 인덱스 접근 타입 T[K]는 속성의 타입을 꺼내며, 키 자리에 유니온이나 keyof T를 넣으면 각 속성 타입의 유니온이 됩니다.

연습 문제

1. 닫힌 키와 업무 규칙

const closedScores: Record<"math" | "english", number> = { math: 90 };은 검사를 통과하나요? scoreOrZero의 규칙을 "없는 과목은 -1, 실제 0점은 0"으로 바꾸려면 반환식을 어떻게 써야 하나요?

해설 보기

closedScores는 두 키를 모두 요구하므로 english가 빠져 진단됩니다. 반환식은 score === undefined ? -1 : score로 바꿉니다. score ? score : -1처럼 참거짓으로 검사하면 실제 0점도 거짓으로 보아 -1이 되므로, 규칙과 맞지 않습니다.

2. 인덱스 시그니처와 다른 속성

interface Settings { theme: string; version: number; [key: string]: string; }는 어디에서 진단되나요? 두 가지 고치는 방법과 각각의 대가를 적어 보세요.

해설 보기

version: number가 문자열 인덱스 시그니처의 값 string과 맞지 않아 TS2411이 나옵니다. 인덱스 시그니처의 값을 string | number로 넓히면 진단은 사라지지만, 이름을 적지 않은 모든 키의 값도 숫자를 허용하게 됩니다. 또는 자유로운 키를 extra: Record<string, string> 같은 별도의 속성으로 옮기면, theme과 version은 정확한 타입을 지키면서 나머지 키만 열린 사전으로 둘 수 있습니다.

3. 꺼낸 타입 계산하기

type Order = { id: number; items: { name: string; qty: number }[]; paid: boolean };일 때, Order["items"][number]["qty"]와 Order["id" | "paid"]는 각각 어떤 타입인가요?

해설 보기

Order["items"]는 배열 타입 { name: string; qty: number }[]이고, 배열 타입에 [number]를 쓰면 요소의 타입이 나옵니다. 그 요소의 ["qty"]는 number이므로 첫 결과는 number입니다. Order["id" | "paid"]는 id의 number와 paid의 boolean을 모아 number | boolean이 됩니다.

다음 글 유틸리티 타입에서는 이 장에서 만든 원리로 만들어진 표준 유틸리티 타입을 실제 쓰임새와 함께 정리합니다.