본문으로 건너뛰기

제네릭 타입 — Array·Map·내 타입

타입스크립트 목차제네릭레슨 34 / 51

목차

제네릭 타입(generic type)은 Map<K, V>처럼 타입 이름 뒤에 타입 칸을 단 타입입니다. 칸을 채워 쓰면, 그 타입에 넣고 꺼내는 값의 타입이 채운 타입으로 정해집니다.

제약 — extends로 칸에 조건 달기까지 타입 칸은 함수 머리에 달려 있었고, 부를 때마다 채웠습니다. 칸은 타입 이름에도 달 수 있습니다. 이 레슨에서는 배열 타입의 칸, Map의 두 칸, 내가 만든 타입에 단 칸을 차례로 읽습니다.

JS 다시 보기: Map은 키와 값을 짝지어 담습니다. set으로 넣고 get으로 꺼내며, 없는 키를 꺼내면 undefined가 나옵니다(JS 'Map과 Set').

7단계 중 1단계. 칸은 타입 자리입니다. Map은 키의 K 칸과 값의 V 칸을 달고, 만들 때 채웁니다.

1배열 타입 Array의 칸 하나

글자 배열의 타입 string[]은 Array<string>으로도 적습니다. Array는 원소 타입 칸 T 하나를 단 제네릭 타입이고, 그 칸에 string을 채운 것이 Array<string>입니다.

const names: Array<string> = [
  '커피',
  '라테',
];
names.push(4000);
const first = names[0];
검사 결과 오류 1개
main.ts(5,12): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

5번 줄의 TS2345는 배열과 튜플에서 수 배열에 글자를 넣었을 때와 같은 오류입니다. push가 받는 값과 [0]으로 꺼내는 값은 Array 안의 T 자리라, T 칸을 string으로 채우면 push는 string만 받고 6번 줄의 first도 string입니다(검사 규칙 7). 편집기에서 names에 마우스를 올리면 string[]로 보입니다. 두 꼴은 같은 타입이고, 이 과목은 짧은 string[]을 씁니다.

2Map의 K 칸과 V 칸

Map은 키의 타입 칸 K와 값의 타입 칸 V를 Map<K, V>처럼 쉼표로 나눠 답니다. 빈 Map을 만들 때 두 칸을 차례로 채웁니다.

const menu =
  new Map<string, number>();
menu.set('커피', 4000);
menu.set('라테', 4500);
const p = menu.get('라테');
console.log(p);
실행 결과
4500

1~2번 줄은 폰 화면에 맞추려고 = 뒤에서 나눈 한 문장입니다. K 칸에 string, V 칸에 number를 채우면 Map 안의 K 자리와 V 자리가 한꺼번에 바뀝니다. set의 머리 (key: K, value: V)가 (key: string, value: number)⁠가 되어, 3번 줄의 '커피'와 4000이 두 자리에 맞춰집니다. 칸을 적지 않은 new Map()은 두 칸이 any로 채워진 Map<any, any>라 검사가 꺼집니다(any는 검사를 끈다). 빈 배열처럼 빈 Map도 칸을 적어 줍니다(검사 규칙 3).

V 칸이 number이니 값 자리에 글자를 넣으면 막힙니다.

const menu =
  new Map<string, number>();
menu.set('커피', 4000);
menu.set('라테', '4500');
검사 결과 오류 1개
main.ts(4,16): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

4번 줄 열 16의 '4500'을 V 자리인 value에 넣으려 해, 'string 타입 인자는 number 타입 매개변수에 넣을 수 없다'는 오류가 납니다.

3get은 undefined도 돌려준다

2절 menu에서 있는 키 라테와 없는 키 녹차를 꺼내, 셈에도 써 봅니다. 1~4번 줄은 2절과 같습니다.

menu 만들기(2절과 같음)
const menu =
  new Map<string, number>();
menu.set('커피', 4000);
menu.set('라테', 4500);

앞 코드에 이어 씁니다.

const p = menu.get('라테');
const q = menu.get('녹차');
console.log(p, q);
console.log(p + 500);

검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? Node로 돌리면 무엇이 찍힐까요?

검사 결과와 실행 결과 보기
검사 결과 오류 1개
main.ts(8,13): error TS18048: 'p' is possibly 'undefined'.
실행 결과
4500 undefined
5000

예상과 다르면 그곳이 배울 곳입니다. p와 q는 둘 다 number | undefined입니다. get의 머리는 get(key: K): V | undefined⁠라, V 칸을 number로 채우면 돌려주는 타입이 number | undefined가 됩니다. 검사기는 실행하기 전에는 그 키가 Map에 들어 있는지 모릅니다. 그래서 있는 키 라테를 꺼낸 p에도 undefined를 붙여 두고, 8번 줄에서 'p는 undefined일 수 있다'는 TS18048로 막습니다. 검사 오류가 있어도 Node는 타입을 지우고 돌립니다(검사 규칙 1). 라테는 있으니 4500에 500을 더한 5000이 찍힙니다. 녹차처럼 없는 키였다면 undefined + 500이라 NaN이 찍힙니다. 8번 줄은 지나가리라 봤다면 꺼낸 값이 늘 있다고 본 것입니다(꺼낸 값은 늘 있다는 생각).

거르는 법은 null·undefined 거르기와 같습니다. 없을 때 쓸 값을 ??로 정하거나, !== undefined로 확인한 갈래 안에서 씁니다.

menu 만들기(2절과 같음)
const menu =
  new Map<string, number>();
menu.set('커피', 4000);
menu.set('라테', 4500);

앞 코드에 이어 두 가지로 걸러 씁니다.

const p = menu.get('라테') ?? 0;
console.log(p + 500);
const q = menu.get('녹차');
if (q !== undefined) {
  console.log(q + 500);
}
실행 결과
5000

5번 줄의 p는 라테가 없으면 0이 되니 number이고, 9번 줄의 q는 8번 줄이 참인 갈래라 number로 좁혀집니다(검사 규칙 5). 녹차는 없어 그 갈래로 들어가지 않으니 5000만 찍힙니다.

꺼낸 값지금 타입
menu.get('라테')number | undefined
menu.get('라테') ?? 0number
!== undefined 갈래 안의 qnumber

4내 타입에 칸 달기

내가 만든 타입에도 칸을 달 수 있습니다. 타입 이름 뒤에 <T>를 적고, 모양 안에서 T를 씁니다. 메뉴를 찾은 결과를 담는 타입 Result를 만들어 봅니다. 찾으면 값을 담고, 못 찾으면 까닭을 담습니다.

interface Ok<T> {
  kind: 'ok';
  value: T;
}
interface Err {
  kind: 'err';
  msg: string;
}
type Result<T> = Ok<T> | Err;

1~4번 줄의 Ok는 찾은 값을 value에 담고, 그 값이 수일지 글자일지는 칸 T로 둡니다. 5~8번 줄의 Err는 못 찾은 까닭을 msg에 담습니다. 9번 줄은 두 모양을 묶은 유니언에 칸 T를 단 이름 Result를 붙이고, 그 T를 Ok의 칸에 넘깁니다. 두 갈래는 kind로 나뉩니다(판별 유니언). Result<number>라고 쓰면 T 칸에 number가 채워져 Ok 갈래가 Ok<number>가 되고, value가 number가 됩니다. Err 갈래에는 T가 없어 그대로입니다.

앞 코드에 이어 결과 둘을 담은 목록을 돌며 꺼냅니다.

const list: Result<number>[] = [
  { kind: 'ok', value: 4500 },
  { kind: 'err', msg: '품절' },
];
for (const r of list) {
  if (r.kind === 'ok') {
    console.log(r.value + 500);
  } else {
    console.log(r.msg);
  }
}
실행 결과
5000
품절

10번 줄의 Result<number>[]는 'T 칸을 number로 채운 Result의 배열'입니다. 15번 줄이 참인 갈래에서 r은 Ok<number>로 좁혀져 16번 줄의 r.value는 number이고, else 갈래의 r은 Err라 msg를 꺼냅니다. 칸이 없었다면 값 타입마다 Ok 모양을 따로 만들어야 했습니다.

예제

검사 결과와 실행 결과를 먼저 적어 본 뒤 펼쳐 보세요. 맞혀 보기는 점수에 들지 않습니다.

키가 정해지지 않은 객체 — 인덱스 시그니처의 세기 코드를 Map으로 옮깁니다. 메뉴 이름이 키, 판매 수가 값입니다.

const today = [
  '커피',
  '라테',
  '커피',
  '쿠키',
];
const sold =
  new Map<string, number>();
for (const item of today) {
  const n = sold.get(item) ?? 0;
  sold.set(item, n + 1);
}
console.log(sold.get('커피'));
console.log(sold.get('녹차'));

검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? Node로 돌리면 무엇이 찍힐까요?

검사 결과와 실행 결과 보기
검사 결과 오류 없음
실행 결과
2
undefined

예상과 다르면 그곳이 배울 곳입니다. 검사는 오류 없이 지나갑니다. 10번 줄의 get은 number | undefined를 돌려주지만, ?? 0이 처음 세는 메뉴의 undefined를 0으로 바꿔 n은 number입니다. 그래서 11번 줄의 n + 1이 V 자리인 value에 맞습니다. 커피는 두 번 나와 2가 찍힙니다. 녹차는 한 번도 세지 않아 Map에 없으니, 14번 줄의 get은 undefined를 돌려줍니다. 13 · 14번 줄에서 막히리라 봤다면 number | undefined인 값은 어디에도 못 쓴다고 본 것입니다. console.log는 어떤 타입의 값이든 받습니다. 막히는 곳은 3절의 p + 500처럼 수가 꼭 있어야 하는 자리입니다. 인덱스 시그니처에서는 없는 칸 sold['빙수']의 지금 타입이 number라 검사기가 짚지 못했습니다. Map의 get은 돌려주는 타입에 undefined가 들어 있어, ?? 0을 빠뜨리면 11번 줄을 짚습니다.

자주 틀리는 곳

틀린 생각: has로 키가 있는지 확인했으면, get으로 꺼낸 값은 늘 들어 있다.

넣어 둔 키이고 has도 true를 돌려주니, 검사기가 짚는 undefined는 틀린 말처럼 보입니다.

menu 만들기(2절과 같음)
const menu =
  new Map<string, number>();
menu.set('커피', 4000);
menu.set('라테', 4500);

앞 코드에 이어 has로 확인한 갈래 안에서 꺼냅니다.

if (menu.has('라테')) {
  const p = menu.get('라테');
  console.log(p + 500);
}
검사 결과 오류 1개
main.ts(7,15): error TS18048: 'p' is possibly 'undefined'.
실행 결과
5000

맞는 규칙: get의 돌려주는 타입은 키와 상관없이 V | undefined입니다. has와 get은 따로 부르는 함수라, 검사기는 has가 true였다는 것을 get이 돌려주는 값에 잇지 않습니다. 꺼낸 값은 ?? 0으로 없을 때의 값을 정하거나 !== undefined로 거른 갈래에서 씁니다(3절 · 검사 규칙 2 · 5). menu.get('라테')!처럼 !로 덮으면 키 이름을 잘못 적었을 때도 검사를 지나갑니다(null·undefined 거르기).

틀린 생각: Array<string>과 string[]은 다른 타입이다.

적는 꼴이 다르니, 한쪽으로 적은 배열을 다른 쪽 자리에 넣으면 막힐 것 같습니다.

const a: string[] = ['커피'];
const b: Array<string> = a;
const c: string[] = b;
console.log(c);
실행 결과
[ '커피' ]

맞는 규칙: string[]은 Array<string>을 줄여 적는 꼴입니다. 같은 타입이라 서로의 자리에 그대로 넣을 수 있고, 편집기는 둘 다 string[]로 보여 줍니다(1절).

확인 문제

1. 도서관에서 회원마다 빌린 책 목록을 Map에 담습니다. 4 · 5 · 7번 줄의 a · b · c는 각각 어떤 타입일까요? Node가 찍을 값도 적어 보세요.

const loans =
  new Map<string, string[]>();
loans.set('민지', ['어린 왕자']);
const a = loans.get('민지');
const b = loans.get('지호');
console.log(a, b);
const c =
  loans.get('지호') ?? [];
console.log(c);
힌트

2번 줄에서 V 칸에 무엇을 채웠는지 먼저 봅니다. get은 V 칸의 타입에 undefined를 더해 돌려줍니다.

정답
이름타입
astring[] | undefined
bstring[] | undefined
cstring[]
실행 결과
[ '어린 왕자' ] undefined
[]

V 칸이 string[]라 get은 string[] | undefined를 돌려줍니다. 민지는 넣어 둔 키라 a에 책 목록이 들어 있지만, 타입은 b와 같습니다. 지호는 없는 키라 b는 undefined이고, c는 ?? []가 undefined를 빈 배열로 바꿔 string[]입니다. 흔한 오답은 둘입니다.

  • a를 string[]로 적음 — 넣어 둔 키라 늘 있다고 본 것입니다(꺼낸 값은 늘 있다는 생각). 검사기는 실행 전에는 키가 있는지 모릅니다(3절).
  • a를 string | undefined로 적음 — V 칸의 string[]을 원소 하나로 읽은 것입니다. get은 키에 짝지은 값 하나, 곧 책 목록 전체를 돌려줍니다.

2. 좌석 번호마다 앉은 사람 이름을 Map에 담습니다. 3 · 4번 줄이 검사를 지나도록 2번 줄의 빈칸을 채우세요. 5번 줄 who의 타입과 Node가 찍을 값도 적어 보세요.

const seats =
  new Map<____>();
seats.set(3, '민지');
seats.set(5, '지호');
const who =
  seats.get(4) ?? '빈자리';
console.log(who);
힌트

Map<K, V>의 칸은 키 · 값 차례입니다. set의 첫 인자가 키, 둘째 인자가 값입니다.

정답

빈칸은 number, string입니다. 3번 줄에서 키는 좌석 번호 3, 값은 이름 '민지'이니 K 칸이 number, V 칸이 string입니다. who는 string입니다. get은 string | undefined를 돌려주지만 ?? '빈자리'가 undefined를 글자로 바꿉니다. 4번 좌석은 넣은 적이 없으니 Node는 빈자리를 찍습니다. 다르게 써도 되는 곳: 칸을 number | string처럼 넓혀도 3 · 4번 줄은 지나갑니다. 다만 V 칸을 넓히면 who의 타입도 함께 넓어집니다. any, any도 지나가지만 검사를 끄는 타입이라 쓰지 않습니다(any는 검사를 끈다). 흔한 오답은 차례를 바꾼 string, number입니다. 이름을 키로 본 것입니다. 그러면 K 칸이 string이라 3 · 4번 줄의 키 3과 5, 6번 줄의 4가 모두 TS2345로 막힙니다.

3. 4절의 Result를 글자 값에도 씁니다. 1~9번 줄은 4절과 같습니다. 검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 막힐까요? 16번 줄 r.value의 타입과 Node가 찍을 값도 적어 보세요.

Result 만들기(4절과 같음)
interface Ok<T> {
  kind: 'ok';
  value: T;
}
interface Err {
  kind: 'err';
  msg: string;
}
type Result<T> = Ok<T> | Err;

앞 코드에 이어 씁니다.

const list: Result<string>[] = [
  { kind: 'ok', value: '라테' },
  { kind: 'err', msg: '품절' },
];
for (const r of list) {
  if (r.kind === 'ok') {
    console.log(r.value.length);
  } else {
    console.log(r.value);
  }
}
힌트

10번 줄에서 T 칸을 무엇으로 채웠는지 먼저 봅니다. 15번 줄의 확인이 참인 갈래와 거짓인 갈래에서 r이 각각 어느 모양인지도 봅니다.

정답

18번 줄이 막힙니다.

검사 결과 오류 1개
main.ts(18,19): error TS2339: Property 'value' does not exist on type 'Err'.
실행 결과
2
undefined

10번 줄에서 T 칸을 string으로 채워 Ok 갈래는 Ok<string>이고, 16번 줄의 r.value는 string입니다. 글자에는 length가 있어 16번 줄은 지나가고, '라테'의 글자 수 2가 찍힙니다. else 갈래의 r은 Err입니다. Err에는 value 줄이 없어 18번 줄이 TS2339로 막힙니다. 'Err 타입에 value 속성이 없다'는 뜻입니다. Node는 타입을 지우고 돌리니, 없는 속성을 읽어 undefined를 찍습니다(검사 규칙 1). 흔한 오답은 둘입니다.

  • 16번 줄이 막힌다 — 4절처럼 value가 number라고 본 것입니다. T 칸은 쓸 때마다 채우고, 10번 줄은 string으로 채웠습니다.
  • 막히는 줄이 없다 — Result에는 늘 value가 있다고 본 것입니다. value는 Ok 갈래에만 있어, kind로 Ok 갈래를 확인한 뒤에만 꺼낼 수 있습니다(판별 유니언).

컴퓨터에서 해 보기(선택) — 점수에 들지 않습니다. 폰으로만 보고 있다면 건너뛰세요. 2절 첫 코드를 main.ts로 저장하고, 2번 줄의 Map<string, number>를 Map<string, string>으로 바꿉니다. 오류가 몇 번 줄 몇 열에 몇 개 날지와 Node가 찍을 값을 먼저 적고, npx tsc로 검사한 뒤 node main.ts로 돌려 맞춰 봅니다. 편집기에서 p에 마우스를 올려 p의 타입도 확인합니다. 검사하고 돌리는 법은 내 컴퓨터에서 검사하고 돌리기에 있습니다.

끝까지 내려오면 저절로 표시됩니다. 이 기기에만 저장합니다.

타입스크립트 목차로