목차
인덱스 시그니처와 Record 제대로 이해하기
지난 시간에는 `interface`와 `type`을 사용해 객체의 구조를 정의하는 방법을 배웠습니다.
interface User {
readonly id: number;
name: string;
email: string;
}객체가 가져야 할 속성 이름과 타입을 미리 알고 있다면 매우 깔끔합니다.
const user: User = {
id: 1,
name: "김타입",
email: "type@example.com",
};`id`, `name`, `email`이라는 속성이 반드시 필요하다는 사실을 TypeScript가 알고 있습니다.
그런데 모든 객체의 속성 이름을 미리 알 수 있는 것은 아닙니다.
학생별 시험 점수를 저장한다고 생각해 보겠습니다.
const scores = {
kim: 90,
lee: 85,
park: 100,
};새로운 학생이 추가되면 속성 이름도 달라집니다.
scores.choi = 95;
scores.han = 88;사용자 이름은 계속 추가될 수 있으므로 모든 속성을 타입에 미리 적는 것은 현실적이지 않습니다.
interface Scores {
kim: number;
lee: number;
park: number;
choi: number;
han: number;
}전국 학생 이름을 모두 타입 파일에 작성하다가는 졸업식이 먼저 열릴지도 모릅니다. 🎓
언어별 번역 메시지도 비슷합니다.
const messages = {
ko: "안녕하세요.",
en: "Hello.",
ja: "こんにちは。",
};지원 언어가 추가될 때마다 키가 늘어날 수 있습니다.
messages.fr = "Bonjour.";
messages.es = "Hola.";속성 이름은 달라지지만 한 가지 규칙은 유지됩니다.
모든 번역 메시지의 값은 문자열이다.이런 동적인 객체를 TypeScript에서는 어떻게 표현할까요?
첫 번째 방법은 인덱스 시그니처(Index Signature)입니다.
interface ScoreMap {
[studentName: string]: number;
}두 번째 방법은 TypeScript가 제공하는 `Record` 유틸리티 타입입니다.
type ScoreMap =
Record<string, number>;두 문법 모두 다음과 같은 규칙을 표현할 수 있습니다.
키는 문자열
값은 숫자이번 시간에는 속성 이름이 계속 바뀌는 객체를 안전하게 다루는 방법을 알아보겠습니다.
1. 이번 시간에 배울 내용
이번 시간에는 다음 내용을 살펴봅니다.
- 동적 객체란 무엇인가?
- 인덱스 시그니처는 무엇인가?
- 문자열 키 인덱스 시그니처
- 숫자 키 인덱스 시그니처
- 심벌 키 인덱스 시그니처
- 고정 속성과 인덱스 시그니처를 함께 사용하는 방법
- 값 타입이 충돌하면 왜 오류가 발생하는가?
- 여러 값 타입을 허용하는 방법
- 읽기 전용 인덱스 시그니처
- 선택적 인덱스 시그니처가 따로 없는 이유
- 존재하지 않는 키에 접근할 때 발생하는 문제
- `noUncheckedIndexedAccess` 설정
- `Record<Key, Value>`는 무엇인가?
- 문자열 키를 사용하는 `Record`
- 리터럴 유니언과 `Record` 조합
- 일부 키만 선택적으로 사용하는 방법
- 중첩된 `Record`
- 인덱스 시그니처와 `Record`의 차이
- 객체와 `Map`을 선택하는 기준
- 실무에서 동적 객체를 안전하게 설계하는 방법
오늘의 핵심 문장은 다음과 같습니다.
키의 이름은 몰라도, 키와 값이 지켜야 할 규칙은 정할 수 있다.
동적 객체는 이름표가 계속 바뀌는 사물함과 비슷합니다.
학생 이름은 매번 달라지지만 각 사물함 안에는 반드시 숫자 점수만 들어가야 합니다. 🔐
2. 속성 이름이 고정된 객체
먼저 일반적인 객체 타입을 살펴보겠습니다.
interface User {
id: number;
name: string;
email: string;
}이 객체는 세 개의 고정된 속성을 가집니다.
id
name
email다른 속성을 객체 리터럴에 추가하면 초과 속성 검사가 발생할 수 있습니다.
const user: User = {
id: 1,
name: "김타입",
email: "type@example.com",
role: "admin",
};`User` 인터페이스에는 `role`이 정의되어 있지 않습니다.
이런 객체는 속성의 종류가 명확하고 제한적입니다.
어떤 키가 있는가?
→ 미리 알고 있음
각 키의 타입은 무엇인가?
→ 속성마다 따로 정의3. 속성 이름이 동적인 객체
이번에는 학생별 점수 객체를 살펴보겠습니다.
const scores = {
kim: 90,
lee: 85,
park: 100,
};이 객체는 다음 규칙을 가집니다.
키
→ 학생 이름
→ 문자열
값
→ 시험 점수
→ 숫자학생 이름은 실행 중에 추가될 수 있습니다.
const studentName = "choi";
const score = 95;
scores[studentName] = score;문제는 TypeScript가 처음 객체의 키만 알고 있다는 것입니다.
const scores = {
kim: 90,
lee: 85,
};추론된 타입은 다음과 비슷합니다.
{
kim: number;
lee: number;
}따라서 새로운 키를 추가하면 오류가 발생할 수 있습니다.
scores.park = 100;TypeScript 입장에서는 `park`라는 속성이 설계도에 없기 때문입니다.
동적인 키를 허용하려면 그 규칙을 타입으로 알려줘야 합니다.
4. 인덱스 시그니처란?
인덱스 시그니처는 객체의 속성 이름을 미리 알 수 없을 때 키와 값의 타입 규칙을 정의하는 문법입니다.
interface ScoreMap {
[studentName: string]: number;
}문법을 분해해 보겠습니다.
[
studentName: string
]: number;각 부분의 의미는 다음과 같습니다.
studentName
→ 키를 설명하기 위한 이름
string
→ 객체 키의 타입
number
→ 해당 키에 저장되는 값의 타입즉, 다음 규칙을 의미합니다.
문자열 키로 접근하면 숫자 값이 나온다.
실제 객체를 만들 수 있습니다.
const scores: ScoreMap = {
kim: 90,
lee: 85,
park: 100,
};새로운 학생 점수도 추가할 수 있습니다.
scores.choi = 95;
scores["han"] = 88;숫자가 아닌 값은 저장할 수 없습니다.
scores.kang = "만점";TypeScript는 `"만점"`이 문자열이므로 오류를 표시합니다.
숫자 점수표에 감상평이 몰래 들어오려다가 입구에서 발견된 것입니다.
5. 인덱스 매개변수 이름은 자유롭게 정할 수 있다
다음 세 인터페이스는 모두 같은 의미입니다.
interface ScoreMapA {
[studentName: string]: number;
}interface ScoreMapB {
[key: string]: number;
}interface ScoreMapC {
[name: string]: number;
}`studentName`, `key`, `name`은 실제 변수나 객체 속성이 아닙니다.
키의 의미를 설명하기 위한 이름입니다.
다음처럼 목적에 맞는 이름을 작성하면 읽기 쉽습니다.
interface TranslationMap {
[languageCode: string]: string;
}interface ProductStockMap {
[productId: string]: number;
}interface UserSettingsMap {
[settingName: string]: boolean;
}단순히 `key`라고 작성해도 되지만 의미 있는 이름을 쓰면 타입 자체가 문서가 됩니다.
6. 문자열 키 인덱스 시그니처
가장 많이 사용하는 형태는 문자열 키입니다.
interface TranslationMap {
[languageCode: string]: string;
}번역 메시지 객체를 만들어 보겠습니다.
const greetings: TranslationMap = {
ko: "안녕하세요.",
en: "Hello.",
ja: "こんにちは。",
};새로운 언어를 추가할 수 있습니다.
greetings.fr = "Bonjour.";
greetings.es = "Hola.";대괄호 표기법도 사용할 수 있습니다.
greetings["de"] = "Hallo.";문자열이 아닌 값은 저장할 수 없습니다.
greetings.it = 100;모든 문자열 키의 값은 `string`이어야 한다는 규칙이 적용됩니다.
7. 동적 키로 객체에 접근하기
인덱스 시그니처가 있으면 문자열 변수로 객체에 접근할 수 있습니다.
interface TranslationMap {
[languageCode: string]: string;
}const greetings: TranslationMap = {
ko: "안녕하세요.",
en: "Hello.",
};사용자의 언어 설정을 변수로 받았다고 가정해 보겠습니다.
const currentLanguage = "ko";동적으로 접근할 수 있습니다.
const message =
greetings[currentLanguage];
console.log(message);출력 결과:
안녕하세요.키 값이 실행 중에 결정되는 상황에서 인덱스 시그니처가 유용합니다.
function getGreeting(
languageCode: string,
messages: TranslationMap
): string {
return messages[languageCode];
}다만 존재하지 않는 키 문제는 잠시 후 별도로 살펴보겠습니다.
8. 타입 별칭으로 인덱스 시그니처 작성하기
인덱스 시그니처는 `interface`뿐 아니라 `type`에서도 사용할 수 있습니다.
type ScoreMap = {
[studentName: string]: number;
};사용 방법은 같습니다.
const scores: ScoreMap = {
kim: 90,
lee: 85,
};scores.park = 100;`interface`와 `type` 중 프로젝트 규칙에 맞는 문법을 선택할 수 있습니다.
interface ScoreMap {
[studentName: string]: number;
}type ScoreMap = {
[studentName: string]: number;
};인덱스 시그니처 자체의 핵심 동작은 동일합니다.
9. 숫자 키 인덱스 시그니처
숫자 키를 사용하는 인덱스 시그니처도 작성할 수 있습니다.
interface NumberMessages {
[messageCode: number]: string;
}객체를 만들어 보겠습니다.
const messages: NumberMessages = {
200: "성공",
400: "잘못된 요청",
404: "찾을 수 없음",
500: "서버 오류",
};숫자 키로 접근합니다.
console.log(messages[200]);
console.log(messages[404]);출력 결과:
성공
찾을 수 없음새로운 코드도 추가할 수 있습니다.
messages[401] = "인증 필요";문자열이 아닌 값은 넣을 수 없습니다.
messages[503] = 503;값 타입이 `string`이므로 오류가 발생합니다.
10. JavaScript 객체의 숫자 키는 어떻게 동작할까?
JavaScript 객체의 일반 속성 키는 내부적으로 문자열이나 심벌로 처리됩니다.
다음 객체를 살펴보겠습니다.
const messages = {
200: "성공",
};다음 두 접근 결과는 같습니다.
console.log(messages[200]);
console.log(messages["200"]);객체의 숫자 키 `200`은 실질적으로 문자열 `"200"`과 연결됩니다.
messages[200]
messages["200"]
두 접근이 같은 속성을 가리킴이 특성 때문에 문자열 인덱스 시그니처와 숫자 인덱스 시그니처를 함께 사용할 때는 값 타입의 관계에 주의해야 합니다.
11. 문자열 키와 숫자 키를 함께 사용하기
다음과 같이 두 종류의 인덱스 시그니처를 함께 정의할 수 있습니다.
interface Collection {
[key: string]:
string | number;
[index: number]:
string;
}숫자 키의 값 타입인 `string`은 문자열 키 값 타입인 `string | number`에 포함됩니다.
이 관계가 필요한 이유는 숫자 키도 문자열 키처럼 접근할 수 있기 때문입니다.
const collection: Collection = {
0: "첫 번째",
1: "두 번째",
total: 2,
};다음 두 접근은 같은 값을 가리킬 수 있습니다.
collection[0];
collection["0"];따라서 숫자 키의 값 타입은 문자열 키에서 허용하는 값 타입과 호환되어야 합니다.
다음처럼 작성하면 문제가 됩니다.
interface WrongCollection {
[key: string]: string;
[index: number]: number;
}숫자 키 값은 `number`인데 문자열 키 규칙에서는 `string`만 허용하고 있습니다.
TypeScript의 키 관리 부서 두 곳이 같은 사물함에 서로 다른 물품 규정을 붙인 셈입니다.
12. 배열도 숫자 인덱스를 사용한다
배열은 숫자 인덱스로 요소에 접근합니다.
const languages = [
"JavaScript",
"TypeScript",
"Python",
];console.log(languages[0]);
console.log(languages[1]);배열 타입은 개념적으로 숫자 인덱스를 사용해 요소 타입을 설명한다고 이해할 수 있습니다.
숫자 인덱스로 접근
→ 문자열 요소 반환다만 실제 배열 타입에는 `length`, `push()`, `map()` 같은 다양한 속성과 메서드가 추가로 포함됩니다.
인덱스 시그니처만 작성한 객체는 배열이 아닙니다.
interface StringCollection {
[index: number]: string;
}다음 객체는 숫자 키를 사용할 뿐 배열 기능은 없습니다.
const collection: StringCollection = {
0: "JavaScript",
1: "TypeScript",
};collection.push("Python");`push()`는 존재하지 않습니다.
숫자 좌석 번호가 있다고 모두 버스는 아닙니다. 🚌
13. 심벌 키 인덱스 시그니처
TypeScript에서는 심벌 키도 표현할 수 있습니다.
interface SymbolRegistry {
[key: symbol]: string;
}심벌을 생성합니다.
const firstKey = Symbol(
"first"
);
const secondKey = Symbol(
"second"
);객체에 값을 저장합니다.
const registry: SymbolRegistry = {
[firstKey]: "첫 번째 값",
[secondKey]: "두 번째 값",
};심벌 키로 접근합니다.
console.log(
registry[firstKey]
);심벌은 일반 문자열 키와 충돌하지 않는 고유한 키를 만들 때 사용할 수 있습니다.
초보 단계에서는 문자열과 숫자 인덱스 시그니처를 중심으로 이해하면 충분합니다.
14. 고정 속성과 인덱스 시그니처 함께 사용하기
동적 속성과 고정 속성을 하나의 객체에 함께 넣고 싶을 수 있습니다.
interface UserScores {
className: string;
[studentName: string]:
string | number;
}`className`은 고정 속성입니다.
나머지 학생 이름 키에는 숫자 점수를 저장할 수 있습니다.
const scores: UserScores = {
className: "TypeScript 입문반",
kim: 90,
lee: 85,
park: 100,
};그런데 왜 인덱스 시그니처의 값 타입이 `number`만이 아니라 `string | number`일까요?
다음처럼 작성해 보겠습니다.
interface WrongScores {
className: string;
[studentName: string]:
number;
}오류가 발생합니다.
문자열 인덱스 시그니처는 모든 문자열 키에 적용되기 때문입니다.
`className`도 문자열 키입니다.
"kim"
→ 문자열 키
"lee"
→ 문자열 키
"className"
→ 이것도 문자열 키따라서 `className`의 값도 인덱스 시그니처의 값 타입과 호환되어야 합니다.
15. 모든 고정 속성이 인덱스 규칙을 따라야 한다
다음 인터페이스를 살펴보겠습니다.
interface ProductInfo {
name: string;
price: number;
[key: string]: number;
}오류가 발생합니다.
`name`은 문자열이지만 인덱스 시그니처는 모든 문자열 키의 값을 숫자로 요구하기 때문입니다.
name
→ 문자열 키
→ 값은 string
인덱스 시그니처
→ 모든 문자열 키의 값은 number
규칙 충돌값 타입을 유니언으로 넓히면 오류를 해결할 수 있습니다.
interface ProductInfo {
name: string;
price: number;
[key: string]:
string | number;
}const product: ProductInfo = {
name: "기계식 키보드",
price: 120000,
stock: 10,
};하지만 값 타입이 넓어졌기 때문에 동적으로 접근한 값은 `string | number`가 됩니다.
const value =
product["stock"];타입별 기능을 사용하려면 좁히기가 필요할 수 있습니다.
if (
typeof value === "number"
) {
console.log(
value.toFixed(0)
);
}16. 고정 정보와 동적 정보를 분리하는 방법
고정 속성과 동적 속성의 타입이 다르다면 객체 구조를 분리하는 편이 더 깔끔할 수 있습니다.
좋지 않은 구조를 살펴보겠습니다.
interface Product {
name: string;
category: string;
[optionName: string]:
string | number;
}모든 동적 값이 `string | number`로 넓어집니다.
동적 옵션을 별도 객체로 분리할 수 있습니다.
interface ProductOptions {
[optionName: string]: number;
}interface Product {
name: string;
category: string;
options: ProductOptions;
}객체를 생성합니다.
const product: Product = {
name: "반팔 티셔츠",
category: "의류",
options: {
small: 10,
medium: 5,
large: 3,
},
};이제 타입 규칙이 선명합니다.
name
→ string
category
→ string
options 안의 모든 값
→ number서로 다른 종류의 물건을 한 서랍에 억지로 넣는 대신 서랍을 나눈 것입니다.
17. 여러 값 타입을 허용하기
동적 객체의 값이 여러 타입 중 하나일 수도 있습니다.
interface SettingMap {
[settingName: string]:
string | number | boolean;
}설정 객체를 만들어 보겠습니다.
const settings: SettingMap = {
theme: "dark",
fontSize: 16,
showSidebar: true,
};새로운 설정을 추가할 수 있습니다.
settings.language = "ko";
settings.timeout = 3000;
settings.debug = false;하지만 배열은 허용되지 않습니다.
settings.plugins = [
"calendar",
"editor",
];배열도 허용하려면 타입에 포함해야 합니다.
interface SettingMap {
[settingName: string]:
| string
| number
| boolean
| string[];
}다만 값 타입이 계속 넓어지면 사용할 때마다 타입 검사가 복잡해집니다.
const value =
settings["theme"];`value`의 타입은 여러 가능성을 가집니다.
string
| number
| boolean
| string[]동적 객체라고 해서 모든 값을 한 창고에 던져 넣는 것이 항상 좋은 설계는 아닙니다.
18. `any` 인덱스 시그니처의 위험
다음 타입은 어떤 값이든 허용합니다.
interface UnsafeData {
[key: string]: any;
}const data: UnsafeData = {
name: "김타입",
age: 25,
skills: [
"TypeScript",
],
profile: {
city: "서울",
},
};겉으로는 편리해 보입니다.
data.notExisting
.wrongMethod();`any`가 사용되므로 TypeScript의 보호를 거의 받을 수 없습니다.
타입을 정말 모른다면 `unknown`이 더 안전할 수 있습니다.
interface UnknownData {
[key: string]: unknown;
}const data: UnknownData = {
name: "김타입",
age: 25,
};값을 사용하기 전에 확인해야 합니다.
const name =
data["name"];
if (
typeof name === "string"
) {
console.log(
name.toUpperCase()
);
}any
→ 검사 없이 사용
unknown
→ 타입 확인 후 사용모르는 물건이 든 상자를 열 때 장갑을 끼는 쪽이 안전합니다.
19. 읽기 전용 인덱스 시그니처
동적 객체의 값을 수정하지 못하게 할 수도 있습니다.
interface ReadonlyMessages {
readonly [
languageCode: string
]: string;
}객체를 생성합니다.
const messages:
ReadonlyMessages = {
ko: "안녕하세요.",
en: "Hello.",
};값을 읽을 수 있습니다.
console.log(
messages.ko
);하지만 기존 값을 변경할 수 없습니다.
messages.ko =
"반갑습니다.";새로운 키를 추가하는 것도 허용되지 않습니다.
messages.ja =
"こんにちは。";읽기 전용 인덱스 시그니처는 설정 목록, 고정 메시지, 변경되면 안 되는 코드표 등에 사용할 수 있습니다.
20. `readonly`는 런타임 동결이 아니다
다음 타입은 TypeScript 수준에서 수정을 막습니다.
interface ReadonlyMessages {
readonly [
key: string
]: string;
}하지만 컴파일 후에는 타입 정보가 사라집니다.
`readonly`는 JavaScript 실행 중 객체를 자동으로 동결하지 않습니다.
런타임 변경까지 막고 싶다면 `Object.freeze()` 같은 방법을 별도로 고려할 수 있습니다.
const messages =
Object.freeze({
ko: "안녕하세요.",
en: "Hello.",
});readonly 인덱스 시그니처
→ 컴파일 단계의 수정 방지
Object.freeze()
→ 실행 단계의 객체 변경 제한두 안전요원은 근무 시간이 다릅니다.
21. 선택적 인덱스 시그니처는 어떻게 표현할까?
고정 속성에는 `?`를 사용할 수 있습니다.
interface User {
nickname?: string;
}하지만 다음과 같은 선택적 인덱스 시그니처 문법은 사용하지 않습니다.
interface ScoreMap {
[studentName: string]?:
number;
}인덱스 시그니처 자체가 “어떤 문자열 키가 존재할지 알 수 없다”는 의미를 이미 포함합니다.
객체에 특정 키가 없어도 됩니다.
interface ScoreMap {
[studentName: string]:
number;
}const scores: ScoreMap = {};아무 학생도 없는 빈 객체도 허용됩니다.
scores.kim = 90;문제는 존재하지 않는 키에 접근했을 때입니다.
22. 존재하지 않는 키에 접근하면?
다음 점수표가 있습니다.
interface ScoreMap {
[studentName: string]:
number;
}const scores: ScoreMap = {
kim: 90,
lee: 85,
};존재하지 않는 학생 이름으로 접근해 보겠습니다.
const score =
scores["unknown"];실행 결과는 다음과 같습니다.
undefined하지만 기본 설정에 따라 TypeScript는 `score`를 `number`처럼 취급할 수 있습니다.
console.log(
score.toFixed(2)
);실행 중에는 `undefined`이므로 오류가 발생할 수 있습니다.
Cannot read properties of undefined타입 설계에서는 모든 문자열 키에 숫자가 있다고 선언했지만 실제 JavaScript 객체는 없는 키에 `undefined`를 반환합니다.
설계도에는 모든 사물함에 물건이 있다고 적혀 있지만 실제로는 아직 비어 있는 사물함이 있을 수 있는 셈입니다.
23. 값 타입에 `undefined` 포함하기
존재하지 않는 키의 가능성을 직접 타입으로 표현할 수 있습니다.
interface ScoreMap {
[studentName: string]:
number | undefined;
}const scores: ScoreMap = {
kim: 90,
lee: 85,
};값을 읽으면 `number | undefined`입니다.
const score =
scores["park"];사용 전에 확인해야 합니다.
if (
score !== undefined
) {
console.log(
score.toFixed(2)
);
} else {
console.log(
"점수가 없습니다."
);
}기본값을 사용할 수도 있습니다.
const safeScore =
scores["park"] ?? 0;24. `noUncheckedIndexedAccess` 설정
TypeScript에는 인덱스 접근을 더 엄격하게 검사하는 설정이 있습니다.
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}이 옵션을 활성화하면 인덱스 시그니처로 접근한 결과에 `undefined` 가능성이 추가됩니다.
interface ScoreMap {
[studentName: string]:
number;
}const scores: ScoreMap = {
kim: 90,
};const score =
scores["park"];개념적인 타입은 다음과 같습니다.
number | undefined따라서 바로 숫자 메서드를 사용할 수 없습니다.
score.toFixed(2);값을 확인합니다.
if (
score !== undefined
) {
console.log(
score.toFixed(2)
);
}동적 키를 많이 사용하는 프로젝트라면 이 설정이 런타임 오류를 줄이는 데 도움이 될 수 있습니다.
25. 인덱스 시그니처와 함수
동적인 메시지 객체를 받는 함수를 만들어 보겠습니다.
interface MessageMap {
[messageKey: string]:
string;
}function getMessage(
messages: MessageMap,
key: string
): string {
return messages[key];
}하지만 키가 존재하지 않으면 `undefined`가 반환될 수 있습니다.
반환 타입을 정확하게 작성할 수 있습니다.
function getMessage(
messages: MessageMap,
key: string
): string | undefined {
return messages[key];
}기본값을 반환하는 방식도 있습니다.
function getMessage(
messages: MessageMap,
key: string
): string {
return (
messages[key]
?? "등록되지 않은 메시지"
);
}호출합니다.
const messages: MessageMap = {
saveSuccess:
"저장되었습니다.",
saveError:
"저장에 실패했습니다.",
};console.log(
getMessage(
messages,
"saveSuccess"
)
);console.log(
getMessage(
messages,
"notFound"
)
);출력 결과:
저장되었습니다.
등록되지 않은 메시지26. `Record`란?
`Record`는 키 타입과 값 타입을 이용해 객체 타입을 만드는 TypeScript의 유틸리티 타입입니다.
기본 문법은 다음과 같습니다.
Record<키타입, 값타입>문자열 키와 숫자 값을 가지는 객체를 만들어 보겠습니다.
type ScoreMap =
Record<string, number>;사용합니다.
const scores: ScoreMap = {
kim: 90,
lee: 85,
park: 100,
};새로운 키를 추가할 수 있습니다.
scores.choi = 95;문자열 값은 허용되지 않습니다.
scores.han = "만점";실무적인 결과는 다음 인덱스 시그니처와 비슷합니다.
type ScoreMap = {
[studentName: string]:
number;
};`Record`는 키와 값의 관계를 짧고 명확하게 표현합니다.
27. `Record`의 두 타입 매개변수
다음 타입을 살펴보겠습니다.
Record<string, number>첫 번째 타입 매개변수는 키 타입입니다.
string두 번째 타입 매개변수는 값 타입입니다.
number따라서 다음과 같이 읽을 수 있습니다.
문자열 키를 가지고
각 값은 숫자인 객체다른 조합도 만들 수 있습니다.
type MessageMap =
Record<string, string>;type BooleanSettings =
Record<string, boolean>;type UserMap =
Record<string, User>;interface User {
id: number;
name: string;
}사용자 객체를 ID별로 저장할 수 있습니다.
const users: UserMap = {
"USER-001": {
id: 1,
name: "김타입",
},
"USER-002": {
id: 2,
name: "이컴파일",
},
};28. 숫자 키를 사용하는 `Record`
숫자 키도 사용할 수 있습니다.
type HttpMessages =
Record<number, string>;const httpMessages:
HttpMessages = {
200: "성공",
400: "잘못된 요청",
404: "찾을 수 없음",
500: "서버 오류",
};숫자 키로 접근합니다.
console.log(
httpMessages[404]
);새로운 코드를 추가합니다.
httpMessages[401] =
"인증이 필요합니다.";객체의 숫자 키는 실행 중 문자열 속성으로 처리될 수 있다는 점은 인덱스 시그니처와 같습니다.
29. 리터럴 유니언과 `Record`
`Record`의 강력한 활용법은 키를 리터럴 유니언으로 제한하는 것입니다.
테마 색상 키를 정의해 보겠습니다.
type ColorName =
| "primary"
| "secondary"
| "danger";각 키의 값은 문자열 색상 코드입니다.
type ThemeColors =
Record<
ColorName,
string
>;객체를 생성합니다.
const colors: ThemeColors = {
primary: "#0066ff",
secondary: "#666666",
danger: "#ff3333",
};세 키가 모두 필요합니다.
하나를 빠뜨리면 오류가 발생합니다.
const colors: ThemeColors = {
primary: "#0066ff",
secondary: "#666666",
};`danger`가 누락되었습니다.
오타도 잡아낼 수 있습니다.
const colors: ThemeColors = {
primary: "#0066ff",
seconday: "#666666",
danger: "#ff3333",
};`secondary`를 `seconday`로 잘못 작성했습니다.
`Record`가 출석부를 들고 모든 키가 도착했는지 검사합니다. 📋
30. 문자열 인덱스와 제한된 키의 차이
다음 타입은 모든 문자열 키를 허용합니다.
type AnyColorMap =
Record<string, string>;const colors: AnyColorMap = {
primary: "#0066ff",
banana: "#ffff00",
invisibleDragon:
"#123456",
};문자열 키라면 무엇이든 사용할 수 있습니다.
반면 다음 타입은 세 키만 허용합니다.
type ColorName =
| "primary"
| "secondary"
| "danger";
type ThemeColors =
Record<
ColorName,
string
>;const colors: ThemeColors = {
primary: "#0066ff",
secondary: "#666666",
danger: "#ff3333",
};허용되지 않은 키를 객체 리터럴에 추가하면 오류가 발생합니다.
const colors: ThemeColors = {
primary: "#0066ff",
secondary: "#666666",
danger: "#ff3333",
banana: "#ffff00",
};차이를 정리하면 다음과 같습니다.
Record<string, string>
→ 어떤 문자열 키든 허용
Record<
"primary" | "secondary",
string
>
→ 지정된 키만 허용31. 모든 키가 필수인 `Record`
리터럴 유니언을 키로 사용하면 `Record`는 모든 키를 필수로 요구합니다.
type Language =
| "ko"
| "en"
| "ja";type GreetingMap =
Record<Language, string>;다음 객체는 정상입니다.
const greetings:
GreetingMap = {
ko: "안녕하세요.",
en: "Hello.",
ja: "こんにちは。",
};하나라도 빠지면 오류가 발생합니다.
const greetings:
GreetingMap = {
ko: "안녕하세요.",
en: "Hello.",
};`ja`가 누락되었습니다.
지원 언어마다 반드시 번역 문구가 있어야 하는 상황에 유용합니다.
번역팀이 일본어 문구를 빠뜨리고 퇴근하려 하면 TypeScript가 출입문을 잠급니다. 🗝️
32. 일부 키만 선택적으로 사용하기
모든 키가 반드시 필요하지 않다면 `Partial`과 `Record`를 조합할 수 있습니다.
type Language =
| "ko"
| "en"
| "ja";type OptionalGreetingMap =
Partial<
Record<
Language,
string
>
>;이제 일부 언어만 작성할 수 있습니다.
const greetings:
OptionalGreetingMap = {
ko: "안녕하세요.",
en: "Hello.",
};빈 객체도 가능합니다.
const greetings:
OptionalGreetingMap = {};각 속성은 선택 사항이므로 접근 결과에 `undefined` 가능성이 있습니다.
const japaneseMessage =
greetings.ja
?? "번역 준비 중";`Partial<T>`는 객체 타입의 모든 속성을 선택적으로 만드는 유틸리티 타입입니다.
이후 유틸리티 타입 회차에서 더 자세히 다룰 예정입니다.
33. 값 자체에 `undefined`를 포함하는 방법
다음 두 타입은 비슷하지만 의미가 다를 수 있습니다.
type FirstMap =
Partial<
Record<
Language,
string
>
>;type SecondMap =
Record<
Language,
string | undefined
>;첫 번째 타입은 키 자체를 생략할 수 있습니다.
const first: FirstMap = {
ko: "안녕하세요.",
};두 번째 타입은 모든 키가 반드시 존재해야 하지만 값이 `undefined`일 수 있습니다.
const second: SecondMap = {
ko: "안녕하세요.",
en: undefined,
ja: undefined,
};다음 코드는 두 번째 타입에서 오류가 발생합니다.
const second: SecondMap = {
ko: "안녕하세요.",
};`en`과 `ja` 키가 빠졌기 때문입니다.
Partial<Record<K, V>>
→ 키를 생략할 수 있음
Record<K, V | undefined>
→ 모든 키는 필요
→ 값이 undefined일 수 있음34. 객체 값을 저장하는 `Record`
`Record`의 값 타입은 기본 타입만 사용할 수 있는 것이 아닙니다.
객체 타입도 사용할 수 있습니다.
interface User {
id: number;
name: string;
isActive: boolean;
}사용자 ID별 객체를 저장합니다.
type UserDictionary =
Record<string, User>;const users:
UserDictionary = {
"USER-001": {
id: 1,
name: "김타입",
isActive: true,
},
"USER-002": {
id: 2,
name: "이컴파일",
isActive: false,
},
};특정 사용자를 가져옵니다.
const user =
users["USER-001"];새로운 사용자도 추가할 수 있습니다.
users["USER-003"] = {
id: 3,
name: "박제네릭",
isActive: true,
};객체 구조가 틀리면 오류가 발생합니다.
users["USER-004"] = {
id: 4,
name: "최레코드",
};`isActive`가 빠졌습니다.
35. 배열과 `Record`는 어떻게 다를까?
사용자 여러 명을 배열로 관리할 수 있습니다.
const users: User[] = [
{
id: 1,
name: "김타입",
isActive: true,
},
{
id: 2,
name: "이컴파일",
isActive: false,
},
];특정 ID를 찾으려면 `find()`를 사용할 수 있습니다.
const user =
users.find(
(user) => user.id === 2
);`Record`로 관리하면 키를 통해 바로 접근할 수 있습니다.
const users:
Record<string, User> = {
"USER-001": {
id: 1,
name: "김타입",
isActive: true,
},
};const user =
users["USER-001"];선택 기준은 다음과 같습니다.
순서가 중요함
반복 순회가 중심
중복 항목도 가능
→ 배열
고유한 키로 빠르게 접근
키와 값의 관계가 중요
→ Record 형태의 객체학생들이 줄을 서야 하면 배열이 적합하고, 이름표가 붙은 사물함에서 바로 찾고 싶다면 `Record`가 적합합니다.
36. 중첩된 `Record`
국가별, 언어별 메시지를 관리한다고 가정해 보겠습니다.
type Language =
| "ko"
| "en";type MessageKey =
| "welcome"
| "logout"
| "save";언어마다 메시지 키를 가집니다.
type Messages =
Record<
Language,
Record<
MessageKey,
string
>
>;객체를 만들어 보겠습니다.
const messages: Messages = {
ko: {
welcome:
"환영합니다.",
logout:
"로그아웃되었습니다.",
save:
"저장되었습니다.",
},
en: {
welcome:
"Welcome.",
logout:
"You have logged out.",
save:
"Saved successfully.",
},
};사용합니다.
console.log(
messages.ko.welcome
);console.log(
messages.en.save
);모든 언어에 모든 메시지 키가 필요합니다.
한 언어에서 `save`를 빠뜨리면 TypeScript가 오류를 표시합니다.
37. 상태별 설정을 `Record`로 관리하기
요청 상태에 따라 화면 메시지를 관리해 보겠습니다.
type RequestStatus =
| "idle"
| "loading"
| "success"
| "error";type StatusMessageMap =
Record<
RequestStatus,
string
>;const statusMessages:
StatusMessageMap = {
idle:
"요청을 기다리고 있습니다.",
loading:
"데이터를 불러오는 중입니다.",
success:
"데이터를 불러왔습니다.",
error:
"오류가 발생했습니다.",
};함수를 작성합니다.
function getStatusMessage(
status: RequestStatus
): string {
return statusMessages[status];
}console.log(
getStatusMessage(
"loading"
)
);조건문을 여러 개 작성하지 않고 상태를 키로 바로 사용할 수 있습니다.
if (
status === "idle"
) {
return "...";
}
if (
status === "loading"
) {
return "...";
}상태가 추가되면 `Record` 객체에도 새 키를 추가해야 하므로 누락을 발견하기 쉽습니다.
38. 함수 목록을 `Record`로 관리하기
값으로 함수 타입도 사용할 수 있습니다.
type Operation =
| "add"
| "subtract"
| "multiply";type CalculatorFunction =
(
firstNumber: number,
secondNumber: number
) => number;type CalculatorMap =
Record<
Operation,
CalculatorFunction
>;객체를 만듭니다.
const calculators:
CalculatorMap = {
add(
firstNumber,
secondNumber
) {
return (
firstNumber +
secondNumber
);
},
subtract(
firstNumber,
secondNumber
) {
return (
firstNumber -
secondNumber
);
},
multiply(
firstNumber,
secondNumber
) {
return (
firstNumber *
secondNumber
);
},
};동적으로 함수를 선택합니다.
function calculate(
operation: Operation,
firstNumber: number,
secondNumber: number
): number {
const calculator =
calculators[operation];
return calculator(
firstNumber,
secondNumber
);
}console.log(
calculate(
"multiply",
10,
20
)
);출력 결과:
200`Record`가 작은 함수 자판기 역할을 합니다. 🥤
버튼을 누르면 해당 키에 연결된 함수가 나옵니다.
39. 인덱스 시그니처와 `Record` 비교하기
다음 두 타입은 실무에서 비슷한 결과를 만들 수 있습니다.
인덱스 시그니처
interface ScoreMap {
[studentName: string]:
number;
}`Record`
type ScoreMap =
Record<string, number>;둘 다 문자열 키와 숫자 값을 가진 객체를 표현합니다.
차이를 간단히 정리하면 다음과 같습니다.
| 구분 | 인덱스 시그니처 | `Record` |
|---|---|---|
| 기본 형태 | 객체 내부 문법 | 유틸리티 타입 |
| 문자열 전체 키 | 가능 | 가능 |
| 숫자 키 | 가능 | 가능 |
| 리터럴 유니언 키 | 직접적인 키 목록 표현은 덜 자연스러움 | 매우 자연스러움 |
| 고정 속성과 함께 작성 | 편리함 | 교차 타입 등으로 조합 |
| 읽기 전용 표현 | `readonly` 직접 사용 | `Readonly<Record<...>>` 등 조합 |
| 선언 병합 | `interface` 사용 시 가능 | 타입 별칭이므로 불가 |
| 간결성 | 세부 규칙 표현에 좋음 | 키와 값 관계를 짧게 표현 |
40. 인덱스 시그니처가 자연스러운 상황
고정 속성과 동적 속성을 같은 객체 구조에서 설명할 때 인덱스 시그니처가 자연스러울 수 있습니다.
interface ErrorResponse {
message: string;
[fieldName: string]:
string | undefined;
}다만 고정 속성도 인덱스 값 타입과 호환되어야 합니다.
사전이나 딕셔너리 형태의 객체를 직접 설명할 때도 사용할 수 있습니다.
interface StringDictionary {
[word: string]:
string;
}인터페이스 선언 병합이 필요한 경우에도 사용할 수 있습니다.
interface AppRegistry {
[pluginName: string]:
unknown;
}41. `Record`가 자연스러운 상황
키 목록이 리터럴 유니언으로 정해져 있을 때 `Record`가 특히 깔끔합니다.
type Role =
| "admin"
| "manager"
| "user";type RoleLabelMap =
Record<Role, string>;const roleLabels:
RoleLabelMap = {
admin: "관리자",
manager: "매니저",
user: "일반 사용자",
};타입을 조합해서 새 객체 타입을 만들 때도 편리합니다.
type Feature =
| "search"
| "download"
| "upload";type FeatureFlags =
Record<Feature, boolean>;const featureFlags:
FeatureFlags = {
search: true,
download: false,
upload: true,
};`Record`는 “모든 키를 빠짐없이 매핑해야 한다”는 의도를 명확하게 보여줍니다.
42. `Record<string, T>`를 무조건 사용하지 말자
다음 타입은 모든 문자열 키를 허용합니다.
type UserData =
Record<string, string>;const user: UserData = {
name: "김타입",
email: "type@example.com",
anything: "어떤 값",
typoEmail: "오타도 통과",
};실제로 키가 `name`, `email`로 고정되어 있다면 일반 객체 타입이 더 안전합니다.
interface User {
name: string;
email: string;
}const user: User = {
name: "김타입",
email: "type@example.com",
};동적 키가 필요하지 않은데 `Record<string, T>`를 사용하면 오타를 잡는 능력이 약해질 수 있습니다.
키가 정해져 있음
→ 일반 객체 타입
키가 계속 달라짐
→ 인덱스 시그니처 또는 Record
키 목록은 정해져 있지만 반복 매핑이 필요
→ 리터럴 유니언 + Record모든 문을 자동문으로 만들 필요는 없습니다.
중요한 금고 문에는 정해진 열쇠가 더 안전합니다.
43. 객체 키를 반복하기
인덱스 시그니처 객체의 값을 반복할 수 있습니다.
type ScoreMap =
Record<string, number>;const scores: ScoreMap = {
kim: 90,
lee: 85,
park: 100,
};`Object.entries()`를 사용할 수 있습니다.
Object
.entries(scores)
.forEach(
(
[
studentName,
score,
]
) => {
console.log(
`${studentName}: ${score}점`
);
}
);출력 결과:
kim: 90점
lee: 85점
park: 100점합계를 계산할 수도 있습니다.
const totalScore =
Object
.values(scores)
.reduce(
(
total,
score
) => {
return (
total + score
);
},
0
);console.log(totalScore);출력 결과:
27544. 동적 객체에서 키 삭제하기
동적인 객체의 속성을 삭제할 수도 있습니다.
type ScoreMap =
Record<
string,
number | undefined
>;const scores: ScoreMap = {
kim: 90,
lee: 85,
};delete scores.lee;console.log(
scores.lee
);결과:
undefined다만 리터럴 유니언 키를 사용하는 필수 `Record`는 모든 키가 필요하다는 계약을 가집니다.
type Language =
| "ko"
| "en";type Messages =
Record<
Language,
string
>;이런 구조에서 키를 임의로 삭제하는 것은 타입 계약과 맞지 않습니다.
키의 추가와 삭제가 빈번하다면 `Partial<Record<...>>`나 `Map`을 고려할 수 있습니다.
45. 객체와 `Map`의 차이
JavaScript에는 동적인 키와 값을 관리하는 `Map`도 있습니다.
const scores =
new Map<
string,
number
>();값을 추가합니다.
scores.set("kim", 90);
scores.set("lee", 85);값을 가져옵니다.
const kimScore =
scores.get("kim");`get()` 결과는 값이 없을 수 있으므로 다음 타입을 가집니다.
number | undefined키 존재 여부를 확인할 수 있습니다.
if (
scores.has("kim")
) {
console.log(
scores.get("kim")
);
}키를 삭제합니다.
scores.delete("lee");항목 개수는 `size`로 확인합니다.
console.log(scores.size);46. 객체가 적합한 상황
다음과 같은 경우에는 객체가 자연스럽습니다.
- JSON으로 변환해 서버와 주고받을 데이터
- 설정 객체
- 문자열 또는 숫자 키 중심의 단순한 딕셔너리
- 점 표기법으로 자주 접근하는 데이터
- 일반적인 객체 구조와 함께 사용하는 데이터
- 리터럴 유니언 키를 빠짐없이 매핑하는 데이터
type StatusMessageMap =
Record<
RequestStatus,
string
>;객체는 `JSON.stringify()`로 바로 변환하기 쉽습니다.
const jsonText =
JSON.stringify(
statusMessages
);47. `Map`이 적합한 상황
다음과 같은 경우에는 `Map`을 고려할 수 있습니다.
- 키가 자주 추가되고 삭제되는 컬렉션
- 문자열이나 숫자가 아닌 객체를 키로 사용
- 항목 개수를 자주 확인
- `set()`, `get()`, `has()`, `delete()` 같은 명확한 컬렉션 API 필요
- 삽입 순서대로 반복하는 컬렉션
- 데이터 자체보다 키와 값의 저장소 역할이 중심
객체를 키로 사용할 수도 있습니다.
interface User {
id: number;
name: string;
}const firstUser: User = {
id: 1,
name: "김타입",
};const lastLoginMap =
new Map<
User,
string
>();lastLoginMap.set(
firstUser,
"2026-08-02"
);일반 객체에서는 객체 자체를 그대로 속성 키로 사용하는 것이 자연스럽지 않습니다.
48. 객체와 `Map` 비교표
| 구분 | 객체 | `Map` |
|---|---|---|
| 키 종류 | 주로 문자열, 숫자, 심벌 | 거의 모든 값 |
| 값 추가 | `obj[key] = value` | `map.set(key, value)` |
| 값 조회 | `obj[key]` | `map.get(key)` |
| 존재 확인 | `key in obj` 등 | `map.has(key)` |
| 삭제 | `delete obj[key]` | `map.delete(key)` |
| 개수 확인 | 별도 계산 | `map.size` |
| JSON 변환 | 바로 가능 | 별도 변환 필요 |
| 타입 표현 | 인덱스 시그니처, `Record` | `Map<K, V>` |
| 고정 키 완전성 검사 | `Record`가 강력 | 직접 관리 필요 |
둘 중 어느 하나가 항상 더 좋은 것은 아닙니다.
객체는 데이터 구조에 가깝고, `Map`은 키와 값 컬렉션에 가깝습니다.
49. 실습 1: 학생 점수표 만들기
학생 점수 타입을 정의합니다.
type ScoreMap =
Record<
string,
number
>;점수표를 만듭니다.
const scores: ScoreMap = {
kim: 90,
lee: 85,
park: 100,
};점수를 추가하는 함수를 작성합니다.
function setScore(
scores: ScoreMap,
studentName: string,
score: number
): void {
if (
score < 0 ||
score > 100
) {
throw new Error(
"점수는 0점부터 100점까지 입력할 수 있습니다."
);
}
scores[studentName] =
score;
}점수를 조회하는 함수를 작성합니다.
function getScore(
scores: ScoreMap,
studentName: string
): number | undefined {
return scores[
studentName
];
}평균을 계산합니다.
function calculateAverage(
scores: ScoreMap
): number {
const scoreList =
Object.values(scores);
if (
scoreList.length === 0
) {
return 0;
}
const total =
scoreList.reduce(
(
sum,
score
) => {
return sum + score;
},
0
);
return (
total /
scoreList.length
);
}사용합니다.
setScore(
scores,
"choi",
95
);const choiScore =
getScore(
scores,
"choi"
);console.log(
`최초이 점수: ${
choiScore ?? "미등록"
}`
);console.log(
`평균 점수: ${
calculateAverage(scores)
}`
);50. 실습 2: 다국어 메시지 관리하기
지원 언어를 정의합니다.
type Language =
| "ko"
| "en"
| "ja";메시지 키를 정의합니다.
type MessageKey =
| "welcome"
| "save"
| "logout";언어별 메시지 타입을 만듭니다.
type LanguageMessages =
Record<
MessageKey,
string
>;전체 메시지 타입을 만듭니다.
type AppMessages =
Record<
Language,
LanguageMessages
>;메시지를 작성합니다.
const messages:
AppMessages = {
ko: {
welcome:
"환영합니다.",
save:
"저장되었습니다.",
logout:
"로그아웃되었습니다.",
},
en: {
welcome:
"Welcome.",
save:
"Saved successfully.",
logout:
"You have logged out.",
},
ja: {
welcome:
"ようこそ。",
save:
"保存しました。",
logout:
"ログアウトしました。",
},
};메시지 조회 함수를 작성합니다.
function getMessage(
language: Language,
messageKey: MessageKey
): string {
return (
messages[
language
][
messageKey
]
);
}사용합니다.
console.log(
getMessage(
"ko",
"welcome"
)
);console.log(
getMessage(
"en",
"save"
)
);키 조합이 타입으로 제한되기 때문에 잘못된 언어나 메시지 키를 전달할 수 없습니다.
getMessage(
"fr",
"welcome"
);getMessage(
"ko",
"delete"
);둘 다 허용 목록에 없습니다.
51. 실습 3: 권한별 기능 설정
사용자 역할을 정의합니다.
type UserRole =
| "admin"
| "manager"
| "user";기능 이름을 정의합니다.
type Permission =
| "read"
| "write"
| "delete";각 역할에 기능별 허용 여부를 연결합니다.
type PermissionSet =
Record<
Permission,
boolean
>;type RolePermissions =
Record<
UserRole,
PermissionSet
>;권한표를 만듭니다.
const permissions:
RolePermissions = {
admin: {
read: true,
write: true,
delete: true,
},
manager: {
read: true,
write: true,
delete: false,
},
user: {
read: true,
write: false,
delete: false,
},
};권한을 확인하는 함수를 작성합니다.
function hasPermission(
role: UserRole,
permission: Permission
): boolean {
return (
permissions[
role
][
permission
]
);
}사용합니다.
console.log(
hasPermission(
"manager",
"write"
)
);출력 결과:
trueconsole.log(
hasPermission(
"user",
"delete"
)
);출력 결과:
false역할이나 권한이 추가되면 `Record`가 빠진 설정을 알려줍니다.
보안팀보다 먼저 출석 검사를 시작하는 셈입니다. 🛡️
52. 실습 4: 상품 재고 관리하기
상품 재고 타입을 정의합니다.
interface StockMap {
[productId: string]:
number | undefined;
}재고 객체를 만듭니다.
const stocks: StockMap = {
"PRODUCT-001": 10,
"PRODUCT-002": 0,
"PRODUCT-003": 5,
};재고를 변경하는 함수를 작성합니다.
function updateStock(
stocks: StockMap,
productId: string,
quantity: number
): void {
if (quantity < 0) {
throw new Error(
"재고는 음수가 될 수 없습니다."
);
}
stocks[productId] =
quantity;
}재고를 감소시키는 함수를 작성합니다.
function decreaseStock(
stocks: StockMap,
productId: string,
quantity: number
): boolean {
const currentStock =
stocks[productId];
if (
currentStock ===
undefined
) {
return false;
}
if (
currentStock <
quantity
) {
return false;
}
stocks[productId] =
currentStock -
quantity;
return true;
}사용합니다.
const success =
decreaseStock(
stocks,
"PRODUCT-001",
3
);console.log(success);console.log(
stocks[
"PRODUCT-001"
]
);출력 결과:
true
753. 실습 5: 동적 폼 오류 메시지
폼 필드 이름은 화면마다 달라질 수 있습니다.
오류 메시지 타입을 정의합니다.
type FormErrors =
Record<
string,
string | undefined
>;검증 함수를 작성합니다.
interface SignupForm {
email: string;
password: string;
userName: string;
}function validateSignupForm(
form: SignupForm
): FormErrors {
const errors:
FormErrors = {};
if (
form.email
.trim()
.length === 0
) {
errors.email =
"이메일을 입력해 주세요.";
}
if (
form.password.length <
8
) {
errors.password =
"비밀번호는 8자 이상이어야 합니다.";
}
if (
form.userName
.trim()
.length === 0
) {
errors.userName =
"이름을 입력해 주세요.";
}
return errors;
}사용합니다.
const errors =
validateSignupForm({
email: "",
password: "1234",
userName: "김타입",
});console.log(
errors.email
);console.log(
errors.password
);출력 결과:
이메일을 입력해 주세요.
비밀번호는 8자 이상이어야 합니다.54. 자주 발생하는 실수
실수 1. 동적 키가 필요한데 고정 객체만 사용하기
const scores = {
kim: 90,
};scores.lee = 85;새로운 키가 계속 추가된다면 동적 객체 타입을 작성합니다.
type ScoreMap =
Record<string, number>;실수 2. 모든 값을 `any`로 허용하기
interface DataMap {
[key: string]: any;
}타입 검사가 거의 사라집니다.
값 타입을 알고 있다면 구체적으로 작성합니다.
interface DataMap {
[key: string]:
string | number;
}정말 모른다면 `unknown`을 고려합니다.
interface DataMap {
[key: string]:
unknown;
}실수 3. 고정 속성이 인덱스 값 타입과 충돌하기
interface Product {
name: string;
[key: string]:
number;
}`name`은 문자열인데 모든 문자열 키의 값은 숫자라고 선언했습니다.
구조를 분리하거나 값 타입을 조정해야 합니다.
interface Product {
name: string;
options:
Record<
string,
number
>;
}실수 4. 존재하지 않는 키도 항상 값이 있다고 생각하기
const score =
scores["unknown"];
console.log(
score.toFixed(2)
);실제 결과는 `undefined`일 수 있습니다.
const score =
scores["unknown"];
if (
score !== undefined
) {
console.log(
score.toFixed(2)
);
}실수 5. 정해진 키에도 `Record<string, T>` 사용하기
type User =
Record<string, string>;const user: User = {
naem: "김타입",
emali:
"type@example.com",
};오타를 발견하지 못할 수 있습니다.
키가 정해져 있다면 일반 객체 타입을 사용합니다.
interface User {
name: string;
email: string;
}실수 6. 리터럴 `Record`에서 키를 빠뜨리기
type Theme =
| "light"
| "dark"
| "system";const labels:
Record<
Theme,
string
> = {
light: "라이트",
dark: "다크",
};`system`이 빠졌으므로 오류가 발생합니다.
모든 키가 필요하지 않다면 `Partial`을 사용합니다.
const labels:
Partial<
Record<
Theme,
string
>
> = {
light: "라이트",
dark: "다크",
};실수 7. `Record`를 배열처럼 사용하기
const users:
Record<
string,
User
> = {};users.push({
id: 1,
name: "김타입",
});`Record` 객체에는 배열의 `push()`가 없습니다.
키로 값을 저장해야 합니다.
users["USER-001"] = {
id: 1,
name: "김타입",
};실수 8. `Map`이 자동으로 JSON 변환된다고 생각하기
const scores =
new Map<
string,
number
>();
scores.set("kim", 90);console.log(
JSON.stringify(scores)
);일반 객체와 같은 형태로 바로 변환되지 않습니다.
필요하다면 엔트리를 객체로 변환할 수 있습니다.
const scoreObject =
Object.fromEntries(
scores
);console.log(
JSON.stringify(
scoreObject
)
);실수 9. 타입이 너무 넓은 동적 설정 객체 만들기
type Settings =
Record<
string,
string
| number
| boolean
| object
| unknown[]
>;값을 사용할 때마다 복잡한 타입 확인이 필요합니다.
관련 설정끼리 구조를 나누는 편이 좋습니다.
interface AppSettings {
theme: {
mode:
| "light"
| "dark";
fontSize: number;
};
features:
Record<
string,
boolean
>;
}55. 인덱스 시그니처와 `Record` 선택 공식
동적 객체 타입을 선택할 때 다음 질문을 확인해 보세요.
질문 1. 키 이름이 미리 정해져 있는가?
정해져 있다면 일반 객체 타입을 사용합니다.
interface User {
name: string;
email: string;
}질문 2. 모든 문자열 키를 허용해야 하는가?
인덱스 시그니처나 `Record<string, T>`를 고려합니다.
type ScoreMap =
Record<string, number>;질문 3. 키 목록이 리터럴 유니언으로 제한되어 있는가?
`Record`가 자연스럽습니다.
type Status =
| "loading"
| "success"
| "error";
type StatusMessages =
Record<
Status,
string
>;질문 4. 고정 속성과 동적 속성을 함께 표현해야 하는가?
인덱스 시그니처를 사용하거나 동적 영역을 별도 객체로 분리합니다.
interface Product {
name: string;
stockBySize:
Record<
string,
number
>;
}질문 5. 일부 키를 생략할 수 있는가?
`Partial<Record<K, V>>`를 고려합니다.
type OptionalLabels =
Partial<
Record<
Status,
string
>
>;질문 6. 없는 키에 접근할 가능성이 있는가?
반환 타입에 `undefined`를 포함하거나 `noUncheckedIndexedAccess`를 활성화합니다.
number | undefined질문 7. 키가 자주 추가되고 삭제되는가?
`Map<K, V>`가 더 자연스러운지 검토합니다.
질문 8. 객체 자체를 키로 사용해야 하는가?
`Map`을 사용합니다.
Map<User, string>질문 9. JSON 데이터로 바로 변환해야 하는가?
일반 객체나 `Record`가 편리할 수 있습니다.
질문 10. 동적 키가 정말 필요한가?
키가 고정되어 있다면 인덱스 시그니처보다 명시적인 객체 타입이 더 안전합니다.
56. 미니 퀴즈
문제 1
다음 인덱스 시그니처의 의미는 무엇일까요?
interface ScoreMap {
[studentName: string]:
number;
}정답
문자열 키를 사용하며, 각 키의 값은 숫자여야 하는 객체를 의미합니다.
문제 2
다음 코드에서 오류가 발생하는 이유는 무엇일까요?
interface ScoreMap {
[key: string]:
number;
}
const scores: ScoreMap = {
kim: 90,
lee: "85점",
};정답
`lee`의 값이 문자열이기 때문입니다.
모든 값은 숫자여야 합니다.
문제 3
다음 인터페이스에 문제가 있는 이유는 무엇일까요?
interface Product {
name: string;
[key: string]:
number;
}정답
`name`도 문자열 키인데 값 타입이 `string`입니다.
문자열 인덱스 시그니처는 모든 문자열 키의 값을 `number`로 요구하므로 충돌합니다.
문제 4
다음 두 타입은 실무적으로 비슷한 객체를 표현할까요?
type FirstMap = {
[key: string]:
number;
};type SecondMap =
Record<string, number>;정답
네.
둘 다 문자열 키와 숫자 값을 가지는 동적 객체를 표현할 수 있습니다.
문제 5
다음 타입에서 필요한 키는 무엇일까요?
type Theme =
| "light"
| "dark"
| "system";
type ThemeLabels =
Record<
Theme,
string
>;정답
다음 세 키가 모두 필요합니다.
light
dark
system문제 6
일부 테마 라벨만 작성할 수 있도록 타입을 수정해 보세요.
정답 예시
type ThemeLabels =
Partial<
Record<
Theme,
string
>
>;문제 7
다음 접근 결과가 `undefined`일 수 있는 이유는 무엇일까요?
const score =
scores["not-found"];정답
JavaScript 객체는 존재하지 않는 키로 접근하면 `undefined`를 반환하기 때문입니다.
동적인 키에 접근할 때는 값이 실제로 존재하는지 확인해야 합니다.
문제 8
다음 데이터에는 배열과 `Record` 중 무엇이 더 자연스러울까요?
USER-001 → 사용자 객체
USER-002 → 사용자 객체
USER-003 → 사용자 객체정답
고유한 사용자 키로 바로 접근하는 것이 중요하다면 `Record<string, User>`가 자연스럽습니다.
순서나 반복 처리가 더 중요하다면 배열도 고려할 수 있습니다.
문제 9
객체 자체를 키로 사용해야 한다면 객체와 `Map` 중 무엇이 적합할까요?
정답
`Map`이 적합합니다.
const map =
new Map<
User,
string
>();문제 10
다음 코드의 더 안전한 대안을 작성해 보세요.
interface DataMap {
[key: string]: any;
}정답 예시
값의 타입을 모른다면 `unknown`을 사용할 수 있습니다.
interface DataMap {
[key: string]:
unknown;
}값 타입을 알고 있다면 구체적인 유니언 타입을 작성하는 것이 더 좋습니다.
57. 핵심 정리
인덱스 시그니처
동적인 객체 키와 값의 타입을 정의합니다.
interface ScoreMap {
[studentName: string]:
number;
}문자열 키
interface MessageMap {
[messageKey: string]:
string;
}숫자 키
interface HttpMessages {
[statusCode: number]:
string;
}고정 속성도 인덱스 규칙을 따라야 함
interface Product {
name: string;
[key: string]:
string | number;
}문자열 키인 `name`도 인덱스 값 타입과 호환되어야 합니다.
읽기 전용 인덱스 시그니처
interface Messages {
readonly [
key: string
]: string;
}없는 키는 `undefined`일 수 있음
const value =
dictionary[
"not-found"
];`noUncheckedIndexedAccess` 설정이나 `undefined` 포함 타입을 고려할 수 있습니다.
`Record`
키 타입과 값 타입으로 객체 구조를 만듭니다.
type ScoreMap =
Record<
string,
number
>;리터럴 유니언과 `Record`
type Status =
| "idle"
| "loading"
| "success";
type StatusMessages =
Record<
Status,
string
>;모든 상태 키가 필요합니다.
선택적 `Record`
type OptionalMessages =
Partial<
Record<
Status,
string
>
>;일부 키를 생략할 수 있습니다.
객체와 `Map`
JSON과 객체 구조 중심
→ 객체 또는 Record
동적인 컬렉션 기능 중심
→ Map58. 마무리
일반적인 객체 타입은 속성 이름을 미리 알고 있을 때 강력합니다.
interface User {
id: number;
name: string;
email: string;
}하지만 학생 이름, 상품 ID, 언어 코드처럼 키가 계속 바뀌는 데이터도 있습니다.
const scores = {
kim: 90,
lee: 85,
park: 100,
};이럴 때 인덱스 시그니처로 동적 키의 규칙을 정의할 수 있습니다.
interface ScoreMap {
[studentName: string]:
number;
}`Record`를 사용하면 키와 값의 관계를 더 짧게 표현할 수 있습니다.
type ScoreMap =
Record<
string,
number
>;키 목록이 정해져 있다면 리터럴 유니언과 `Record`를 조합할 수 있습니다.
type RequestStatus =
| "idle"
| "loading"
| "success"
| "error";type StatusMessages =
Record<
RequestStatus,
string
>;이제 상태가 추가되면 메시지 객체에도 빠짐없이 값을 추가해야 합니다.
이번 편의 핵심을 한 문장으로 정리하면 다음과 같습니다.
인덱스 시그니처는 동적인 키의 규칙을 정의하고, Record는 키 집합과 값 타입의 관계를 명확하게 매핑한다.
다만 동적인 객체라고 해서 무조건 `Record<string, any>`를 사용해서는 안 됩니다.
키가 고정되어 있다면 명시적인 객체 타입이 더 안전합니다.
값의 타입을 모른다면 `any`보다 `unknown`이 낫습니다.
없는 키에 접근할 수 있다면 `undefined` 가능성도 고려해야 합니다.
그리고 키의 추가와 삭제가 컬렉션의 핵심이라면 `Map`이 더 잘 어울릴 수 있습니다.
좋은 타입 설계는 모든 문을 열어두는 것이 아닙니다.
어떤 문은 고정하고, 어떤 문은 확장 가능하게 만들며, 열리는 문에도 출입 규칙을 붙이는 일입니다.
동적 객체는 무법지대가 아닙니다.
이름 모를 열쇠들이 드나드는 곳에도 TypeScript는 질서 정연한 열쇠 보관함을 설치할 수 있습니다. 🗝️
59. 다음 편 예고
지금까지 우리는 객체의 키를 미리 알 수 없는 상황을 다뤘습니다.
type ScoreMap =
Record<
string,
number
>;그리고 정해진 키 목록을 `Record`에 전달하는 방법도 살펴봤습니다.
type Status =
| "idle"
| "loading"
| "success";type StatusMessages =
Record<
Status,
string
>;그런데 이미 정의된 객체 타입에서 키 이름만 꺼내 새로운 타입으로 만들 수는 없을까요?
interface User {
id: number;
name: string;
email: string;
}다음처럼 객체의 키만 허용하는 타입을 만들고 싶습니다.
type UserKey =
| "id"
| "name"
| "email";속성을 추가할 때마다 `UserKey`도 직접 수정해야 한다면 두 타입이 서로 어긋날 수 있습니다.
TypeScript에는 객체 타입의 키를 자동으로 추출하는 `keyof`가 있습니다.
type UserKey =
keyof User;결과는 다음과 같습니다.
"id" | "name" | "email"그렇다면 실제 객체 값에서 타입을 가져오는 방법도 있을까요?
const config = {
theme: "dark",
fontSize: 16,
};`typeof`를 타입 위치에서 사용하면 값으로부터 타입을 만들 수 있습니다.
type Config =
typeof config;`keyof`와 `typeof`를 함께 사용하면 실제 객체의 키를 기반으로 안전한 함수를 만들 수도 있습니다.
function getProperty(
object: User,
key: keyof User
) {
return object[key];
}이제 존재하지 않는 키는 전달할 수 없습니다.
getProperty(
user,
"password"
);다음 편에서는 객체 안에 숨어 있는 키와 타입 정보를 꺼내 재사용하는 방법을 알아보겠습니다.
다음 이야기
[TypeScript 완전정복 #15] 객체의 키를 타입으로 꺼내 쓰는 방법 | keyof와 typeof 완벽 이해하기
- `keyof`는 무엇일까요?
- 객체 타입의 키를 유니언으로 만들려면 어떻게 해야 할까요?
- `typeof`는 실행 코드와 타입 위치에서 어떻게 다를까요?
- 실제 객체 값으로부터 타입을 만들 수 있을까요?
- `keyof typeof`는 언제 사용할까요?
- 존재하는 속성 이름만 함수에 전달하려면 어떻게 해야 할까요?
- 객체의 값 타입을 꺼내려면 어떻게 해야 할까요?
- 배열 값으로 리터럴 유니언을 만들 수 있을까요?
- `Object.keys()`의 결과는 왜 단순한 `string[]`일까요?
- `keyof`와 제네릭을 결합하면 무엇이 가능할까요?
다음 편에서는 객체의 열쇠 꾸러미를 타입으로 복사하고, 실제 값에서 설계도를 역으로 만들어내는 `keyof`와 `typeof`를 만나보겠습니다.
