본문으로 건너뛰기

제네릭 — 부를 때 채우는 타입 칸

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

목차

제네릭(generics)은 함수에 비워 둔 타입 칸 T를 두고, 부를 때마다 넘긴 인자에 맞춰 그 칸을 채우는 문법입니다. 함수 하나를 글자 배열에도 수 배열에도 쓰면서, 돌려받는 값의 타입은 그대로 지킵니다.

함수의 입력과 출력에서는 매개변수마다 타입을 하나씩 적었습니다. 그러면 그 타입의 값만 넘길 수 있어서, 하는 일이 같아도 값의 타입마다 함수를 따로 써야 합니다. 이 단원은 타입을 비워 두었다가 부를 때 채우는 칸을 다룹니다. 이 레슨에서는 T가 언제 무엇으로 채워지는지 보고, 검사 규칙 7을 세웁니다.

JS 다시 보기: setTimeout에 넘긴 콜백은 지금 코드가 다 끝난 뒤에 불려 그때의 바깥 변수 값을 읽고, await는 Promise가 끝나기를 기다려 값을 꺼냅니다. 이 단원 끝 레슨 Promise와 async의 타입이 이 셋을 씁니다(JS '나중에 하기: setTimeout' · JS '클로저: 만들어진 칸을 기억하는 함수' · JS 'async와 await').

이 단원을 이미 안다면 단원 연습 문제의 진단 문항부터 풀고 틀린 레슨만 보세요.

5단계 중 1단계. 칸은 타입 자리입니다. T 칸은 비어 있다가 부를 때 채워집니다.

1타입을 하나 적으면 그 타입만 받는다

배열의 첫 원소를 꺼내는 함수를 메뉴 이름에 쓰려고 매개변수를 string[]로 적었습니다. 같은 함수로 가격 배열의 첫 원소도 꺼내 봅니다. 1~3번 줄은 폰 화면에 맞추려고 매개변수에서 줄을 나눈 함수 머리이고, 한 줄로 쓴 머리와 같습니다.

function first(
  items: string[],
) {
  return items[0];
}
const a = first(['커피', '라테']);
const b = first([4000, 4500]);
console.log(a, b);
검사 결과 오류 2개
main.ts(7,18): error TS2322: Type 'number' is not assignable to type 'string'.main.ts(7,24): error TS2322: Type 'number' is not assignable to type 'string'.
실행 결과
커피 4000

7번 줄이 두 번 막힙니다. 열 18과 24는 4000과 4500이 선 자리이고, 오류 문장은 'number 타입은 string 타입에 넣을 수 없다'는 뜻입니다. 매개변수가 string[]라 원소 하나하나가 string 자리에 들어가야 하는데 수가 왔습니다. 검사 오류가 있어도 Node는 타입을 지우고 돌리므로 커피 4000이 찍힙니다(검사 규칙 1). 함수가 하는 일은 수 배열에도 맞는데, 적어 둔 타입이 막았습니다.

수 배열용 함수를 하나 더 쓰면 같은 몸을 두 번 적게 됩니다. 매개변수를 any[]로 적으면 둘 다 받지만, 돌려받는 값도 any가 되어 검사가 꺼집니다(자주 틀리는 곳). 필요한 것은 타입을 비워 두었다가, 부를 때 넘긴 배열에 맞춰 채우는 칸입니다.

2타입 매개변수 T — 부를 때 채우는 칸

함수 이름 뒤의 <T>는 이 함수에 T라는 타입 칸이 있다는 표시이고, T를 타입 매개변수(type parameter)라고 부릅니다. 매개변수가 부를 때 값을 받듯, T는 부를 때 타입을 받습니다. 그림의 코드에 한 줄을 더했습니다.

function first<T>(items: T[]) {
  return items[0];
}
const a = first(['커피', '라테']);
const b = first([4000, 4500]);
console.log(a, b);
실행 결과
커피 4000

1번 줄에서 items의 타입은 T[], 곧 T의 배열입니다. 2번 줄은 그 원소 items[0]을 돌려주니 돌려주는 타입은 T입니다. 함수를 만들 때는 T 칸이 비어 있습니다. 4번 줄에서 검사기는 인자 ['커피', '라테']의 타입 string[]을 T[]에 맞춰 보고 T 칸을 string으로 채웁니다. 그러면 돌려주는 타입도 string이라 a는 string입니다. 5번 줄은 새로 부르는 줄이라 T 칸도 새로 채웁니다. number[]를 T[]에 맞춰 T는 number, b도 number입니다.

줄인자 타입T돌려주는 타입
4string[]stringstring
5number[]numbernumber

검사 규칙 7. 타입 매개변수 T는 부를 때 채워지는 타입 칸입니다. 인자에서 추론하거나 적어 준 타입으로 채우고, 채운 타입이 T가 쓰인 매개변수와 돌려주는 타입 자리에 들어갑니다.

T라는 이름은 관례일 뿐입니다. <Item>처럼 다른 이름을 써도 되고, 그 이름이 쓰인 자리를 모두 같은 칸으로 읽습니다.

부르는 줄 둘에서 돌려받은 값의 length를 찍어 봅니다. 글자에는 글자 수 length가 있고, 수에는 없습니다.

function first<T>(items: T[]) {
  return items[0];
}
const p = first(['커피', '라테']);
console.log(p.length);
const q = first([4000, 4500]);
console.log(q.length);

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

검사 결과와 실행 결과 보기
검사 결과 오류 1개
main.ts(7,15): error TS2339: Property 'length' does not exist on type 'number'.
실행 결과
2
undefined

예상과 다르면 그곳이 배울 곳입니다. 7번 줄이 막힙니다. 6번 줄에서 T 칸을 number로 새로 채워 q는 number이고, 오류 문장은 'number 타입에 length 속성이 없다'는 뜻입니다. 5번 줄은 지나갑니다. p는 string이고 '커피'는 두 글자라 2가 찍힙니다. 7번 줄은 실행하면 수 4000에서 없는 속성을 꺼내 undefined가 찍힙니다. 7번 줄도 지나간다고 봤다면 T를 첫 부르기의 string으로 본 것입니다(T가 한 번 정해진다는 생각).

3채울 타입을 적어 줄 때

T 칸은 인자에서 추론하지 않고 직접 적어 채울 수도 있습니다. 부르는 줄의 함수 이름 뒤에 <number>처럼 채울 타입을 적습니다.

function first<T>(items: T[]) {
  return items[0];
}
const b = first<number>([4000]);
const a =
  first<number>(['커피']);
console.log(a, b);
검사 결과 오류 1개
main.ts(6,18): error TS2322: Type 'string' is not assignable to type 'number'.
실행 결과
커피 4000

5~6번 줄은 한 문장을 폰 화면에 맞추려고 = 뒤에서 나눈 것입니다. 4번 줄은 적어 준 number가 T 칸을 채워 매개변수 칸이 number[]가 됩니다. 인자 [4000]이 맞으니 지나갑니다. 6번 줄도 적어 준 number가 먼저 T 칸을 채웁니다. 그다음 인자를 number[]에 맞춰 보는데 '커피'는 글자라 막힙니다 — 'string 타입은 number 타입에 넣을 수 없다'. 인자로 T 칸을 다시 채우지 않습니다.

보통은 적지 않고 추론에 맡깁니다. 인자를 보면 T가 정해지기 때문입니다. 인자로는 알 수 없을 때 적습니다 — 빈 Map을 만들며 new Map<string, number>()⁠처럼 적는 것은 제네릭 타입 — Array·Map·내 타입에서 봅니다.

예제

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

마지막 원소를 꺼내는 last를 메뉴 이름에도 가격에도 씁니다. 주석 1 · 3은 제네릭 함수로 감싸는 차례입니다. 그 사이 차례 2 '쓰는 속성은 extends로'는 원소의 속성을 쓸 때 필요해서 다음 레슨에서 붙입니다.

// 1. 바뀌는 타입을 T로
function last<T>(items: T[]) {
  const i = items.length - 1;
  // 3. 돌려주는 값은 T로
  return items[i];
}
const drinks = ['커피', '녹차'];
const prices = [4000, 3500];
const drink = last(drinks);
const won = last(prices);
console.log(drink, won + 500);

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

검사 결과와 실행 결과 보기
검사 결과 오류 없음
실행 결과
녹차 4000

예상과 다르면 그곳이 배울 곳입니다. 검사는 오류 없이 지나갑니다. 9번 줄은 drinks의 타입 string[]으로 T 칸을 채워 drink가 string이고, 10번 줄은 T 칸을 number로 새로 채워 won이 number입니다. 그래서 11번 줄의 won + 500은 수 셈입니다. 실행하면 마지막 원소 '녹차'와, 3500에 500을 더한 4000이 찍힙니다. 10번 줄에서 막히리라 봤다면 T를 9번 줄의 string으로 고정해 본 것입니다(T가 한 번 정해진다는 생각). 3번 줄의 items.length는 원소가 아니라 배열의 속성이라 T가 무엇이든 쓸 수 있습니다. 원소의 속성을 쓰는 함수는 제약 — extends로 칸에 조건 달기에서 만듭니다.

자주 틀리는 곳

틀린 생각: 여러 타입을 받는 함수는 매개변수를 any로 적으면 된다.

글자 배열도 수 배열도 받으니, 1절의 막힘이 any 하나로 풀리는 것처럼 보입니다.

function first(items: any[]) {
  return items[0];
}
const c = first([4000, 4500]);
console.log(c.toUpperCase());
검사 결과 오류 없음
실행 결과
main.ts:5console.log(c.toUpperCase());              ^ TypeError: c.toUpperCase is not a function    at main.ts:5:15

any[]의 원소를 돌려주니 c도 any이고, 검사기는 5번 줄의 toUpperCase()를 묻지 않습니다. 실행하면 수 4000에는 그런 메서드가 없어 멈춥니다. 1번 줄을 2절처럼 first<T>(items: T[])로 바꾸면 c는 number라, 검사기가 5번 줄을 TS2339로 막습니다.

맞는 규칙: any는 받은 값과 돌려주는 값의 검사를 끕니다(검사 규칙 6). 여러 타입을 받으면서 돌려주는 타입을 지키려면 타입 매개변수 T를 씁니다(검사 규칙 7).

틀린 생각: T는 처음 부를 때 정해지면 다음 부르기에서도 같다.

함수 머리의 T는 하나뿐이라, 한 번 채우면 그대로 남을 것 같습니다.

맞는 규칙: T 칸은 부르는 줄마다 새로 채웁니다. 같은 first도 4번 줄에서는 string, 5번 줄에서는 number로 채웠습니다(2절 · 검사 규칙 7).

틀린 생각: 제네릭 함수는 부를 때마다 <string>처럼 채울 타입을 적어야 한다.

선언에 <T>가 있으니 부르는 줄에도 적어야 할 것 같습니다.

맞는 규칙: 적지 않으면 검사기가 인자에서 추론해 T 칸을 채웁니다. 적어 주면 그 타입이 먼저 채우고, 인자를 그 타입에 맞춰 봅니다(3절 · 검사 규칙 7).

틀린 생각: 값이 T인지 instanceof T로 실행할 때 확인할 수 있다.

T가 타입 칸이니, 실행 중에 이 값이 T인지 물을 수 있을 것 같습니다.

function isT<T>(v: unknown) {
  return v instanceof T;
}
console.log(isT<string>('라테'));
검사 결과 오류 1개
main.ts(2,23): error TS2693: 'T' only refers to a type, but is being used as a value here.
실행 결과
main.ts:2  return v instanceof T;                      ^ ReferenceError: T is not defined    at isT (main.ts:2:23)    at main.ts:4:13

오류 문장은 'T는 타입을 가리킬 뿐인데 여기서 값으로 쓰였다'는 뜻입니다. T도 다른 타입처럼 실행 전에 지워집니다(타입은 실행 전에 지워진다). 그래서 Node에는 T라는 이름이 없어 ReferenceError로 멈춥니다.

맞는 규칙: T는 검사할 때만 있는 타입 칸이라 실행할 때 물을 수 없습니다. instanceof 오른쪽에는 실행할 때도 남는 클래스(Error 등)만 씁니다(검사 규칙 1).

확인 문제

1. 다음 4~6번 줄에서 검사기는 T 칸을 무엇으로 채울까요? x · y · z의 타입을 적어 보세요.

function first<T>(items: T[]) {
  return items[0];
}
const x = first([true, false]);
const y = first([[1, 2], [3]]);
const z = first(['라테', 4500]);
console.log(x, y, z);
힌트

인자 배열의 타입을 먼저 적고 T[]와 맞춰 봅니다. 원소가 배열인 [[1, 2], [3]]의 타입은 number[][]입니다.

정답

검사는 오류 없이 지나갑니다. 검사기가 채운 T와 돌려주는 타입은 이렇습니다.

이름인자 타입T · 돌려주는 타입
xboolean[]boolean
ynumber[][]number[]
z(string | number)[]string | number

실행하면 배열마다 첫 원소가 찍힙니다.

실행 결과
true [ 1, 2 ] 라테

y는 원소 하나하나가 number[]라 T가 number[]이고, 첫 원소 [1, 2]를 돌려줍니다. z는 글자와 수를 섞은 배열이라 원소 타입이 string | number입니다(배열과 튜플). 흔한 오답은 y를 number[][]로 적는 것입니다. 인자의 타입을 그대로 T로 본 것입니다(인자 타입이 곧 T라는 생각). T는 인자 타입을 T[]에 맞춰 본 원소 자리의 타입입니다.

2. i번째 원소를 꺼내는 함수 pick을 가격 배열에도 메뉴 이름 배열에도 씁니다. 9번 줄의 won이 number, 10번 줄의 tea가 string이 되도록 2번 줄의 빈칸에 타입을 채우세요.

function pick<T>(
  items: ____,
  i: number,
) {
  return items[i];
}
const prices = [4000, 3500];
const drinks = ['커피', '녹차'];
const won = pick(prices, 1);
const tea = pick(drinks, 1);
console.log(won + 500, tea);
힌트

T 칸은 인자를 맞춰 보는 매개변수 자리에 T가 쓰여야 채워집니다. 9번 줄의 인자 prices는 number[]입니다. 이 인자에서 T가 number로 채워지려면 매개변수가 어떤 꼴이어야 할지 봅니다.

정답

빈칸은 T[]입니다. 9번 줄은 number[]를 T[]에 맞춰 T가 number, 10번 줄은 T가 string입니다. 돌려주는 값 items[i]가 T라 won은 number, tea는 string이고, 실행하면 4000 녹차가 찍힙니다. 다르게 써도 되는 곳: 1번 줄의 T를 Item처럼 다른 이름으로 바꾸면 빈칸도 Item[]입니다. 빈칸을 readonly T[]로 적어도 지나갑니다 — pick은 배열을 바꾸지 않습니다. 흔한 오답은 셋입니다.

  • any[] — 검사는 지나가지만 won과 tea가 any라 검사가 꺼집니다(any가 무난하다는 생각).
  • (string | number)[] — 둘 다 받지만 won이 string | number라 11번 줄의 won + 500이 TS2365로 막힙니다.
  • T — T에 무엇이 올지 몰라 items가 배열인지 알 수 없으니, 5번 줄의 items[i]가 TS7053으로 막힙니다.

3. 하루 걸음 수 목록에서 첫날 걸음에 1000을 더하려는 코드가 5번 줄에서 막혔습니다. 검사 오류를 없애고, steps가 number가 되어 실행하면 9000이 찍히게 하는 고치기를 고르세요.

function first<T>(items: T[]) {
  return items[0];
}
const steps =
  first<string>([8000, 6500]);
console.log(steps + 1000);
검사 결과 오류 2개
main.ts(5,18): error TS2322: Type 'number' is not assignable to type 'string'.main.ts(5,24): error TS2322: Type 'number' is not assignable to type 'string'.
  • ① 5번 줄의 <string>을 지운다.
  • ② 5번 줄의 <string>을 <any>로 바꾼다.
  • ③ 5번 줄의 인자를 ['8000', '6500']으로 바꾼다.
힌트

고친 코드마다 T 칸이 무엇으로 채워지는지 정하고, 6번 줄의 steps + 1000이 수 셈인지 글자 잇기인지 봅니다.

정답

①입니다. 적어 준 타입이 없으면 검사기가 인자 number[]에서 T를 number로 채워 steps는 number이고, 실행하면 9000이 찍힙니다.

  • ② 검사는 지나가지만 T를 any로 채워 steps가 number가 아니라 any가 됩니다. 실행 결과는 9000이어도 steps의 검사를 끈 것입니다(any가 무난하다는 생각).
  • ③ 검사는 지나가지만 steps가 글자 '8000'이 되어, 6번 줄이 1000을 이어 붙여 80001000이 찍힙니다. 검사기가 짚은 곳은 인자지만, 고칠 곳은 잘못 적은 <string>이었습니다.

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

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

타입스크립트 목차로