본문 바로가기

제네릭 함수

타입 매개변수로 입력과 반환 타입을 이어 주는 제네릭 함수를 만들고, 타입 인수의 추론과 여러 타입 매개변수, extends 제약, keyof와 인덱스 접근 타입으로 안전한 속성 읽기를 구현한 뒤, 빈 배열처럼 값이 없는 경우를 반환 타입에 넣는 방법을 다룹니다.

배열의 마지막 요소를 돌려주는 함수를 생각해 봅시다. 숫자 배열에서는 숫자를, 문자열 배열에서는 문자열을 돌려주어야 하는데, 매개변수를 number[]로 적으면 문자열 배열을 받지 못하고 any[]로 적으면 돌려준 값의 타입을 잃어버립니다. 배열마다 함수를 따로 만들거나 앞 글의 오버로드를 늘어놓는 것도 끝이 없습니다.

제네릭(generic)은 함수가 다룰 타입을 미리 정하지 않고, 호출할 때 정해지도록 열어 두는 기능입니다. 이 글은 any와 비교해 제네릭이 무엇을 지키는지 보고, 타입 인수의 추론과 제약 조건, keyof를 이용한 속성 읽기를 차례로 익힌 뒤, 처음의 마지막 요소 함수를 완성합니다. 예제는 ts-first 프로젝트에 generics.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

identity의 입력 타입 T가 반환 타입 T와 연결됩니다. pair는 두 타입 변수를 튜플에 보존하고 extends Lengthwise는 length 접근을 허용하는 제약입니다.

TYPE / RUNTIME / CONTRACT

같은 T가 입력과 반환 타입을 연결합니다

identity<T>(arg: T): T는 호출마다 타입 관계를 보존합니다. 제네릭 본문은 모든 허용 T에 맞게 검사되며 호출마다 새 JavaScript 구현이 생기지는 않습니다.

입력과 반환의 타입 관계입력T→identity실행→반환T의3노드2간선관계다. 같은타입을관계로표현하며런타임객체복제나유효성검사생성을뜻하지않는다.
입력 Tstring · number 등
연결 관계입력 T · string · number 등 → identity<T> · 입력 arg 반환
identity<T>입력 arg 반환
연결 관계identity<T> · 입력 arg 반환 → 반환 T · 입력의 타입 관계
반환 T입력의 타입 관계
입력과 반환의 타입 관계 · 모바일입력T→identity실행→반환T의3노드2간선관계다. 같은타입을관계로표현하며런타임객체복제나유효성검사생성을뜻하지않는다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.
입력 Tstring · number 등
연결 관계입력 T · string · number 등 → identity<T> · 입력 arg 반환
identity<T>입력 arg 반환
연결 관계identity<T> · 입력 arg 반환 → 반환 T · 입력의 타입 관계
반환 T입력의 타입 관계

IDENTITY

any와 다르게 타입 정보를 유지

identity<string>("myString") 결과는 string입니다. length는 사용할 수 있지만 toFixed는 오류입니다. 타입 인자를 생략해 추론할 수도 있습니다.

PAIR

두 입력을 서로 다른 변수로 연결

pair("hello", 123)은 [string, number]입니다. 실제 결과의 메서드 호출은 HELLO와 123.00을 출력합니다.

CONSTRAINT

허용 멤버를 명시

T extends Lengthwise는 length: number를 요구합니다. 본문 예제는 문자열 11·배열 3·객체 10을 반환하며 숫자·boolean 인수는 오류입니다.

ERASURE

타입 관계는 검사 시점에 사용

타입 변수와 제약은 JavaScript 출력에서 제거됩니다. 런타임 입력 검증이나 호출마다 별도 코드를 자동 생성하지 않습니다.

T는 입력과 출력 사이의 관계를 표현합니다. 제약을 만족하는 아무 값이나 반환해도 원래 T를 보존하는 것은 아닙니다.

any 대신 타입 매개변수

받은 값을 그대로 돌려주는 identity 함수로 차이를 봅니다. 매개변수와 반환 타입을 any로 적으면 어떤 값이든 받을 수 있지만, 돌려받은 값이 무엇이었는지에 대한 정보가 사라집니다.

function identityAny(arg: any): any {
  return arg;
}

const outputAny = identityAny(123);
console.log(outputAny.length);
undefined

outputAny의 타입은 any이므로, 숫자에는 없는 length를 읽어도 검사를 통과하고 실행하면 undefined가 나옵니다. 함수 하나를 거쳤을 뿐인데, 숫자를 넣었다는 사실을 컴파일러가 더는 알지 못하는 것입니다.

제네릭 함수는 함수 이름 뒤의 꺾쇠괄호 안에 타입 매개변수(type parameter)를 선언합니다. 흔히 T처럼 대문자 한 글자로 쓰며, 타입 변수라고도 부릅니다. identity<T>(arg: T): T는 "어떤 타입 T의 값을 받아, 같은 T의 값을 돌려준다"라는 뜻이어서, 입력과 반환의 타입이 서로 이어집니다.

function identity<T>(arg: T): T {
  return arg;
}

const message: string = "myString";
const output1 = identity<string>(message);
const output2 = identity(message);
const output3 = identity([1, 2, 3]);

console.log(output1.length, output2.toUpperCase(), output3.length);
8 MYSTRING 3

identity<string>(message)처럼 꺾쇠괄호 안에 실제 타입을 적어 넘기는 것을 타입 인수(type argument)라고 합니다. 대부분은 적지 않아도 되는데, 컴파일러가 인수의 타입에서 T를 추론하기 때문입니다. 그래서 output2는 string, output3은 number[]가 되고, output1.toFixed(2)처럼 그 타입에 없는 메서드를 쓰면 Property 'toFixed' does not exist on type 'string'.으로 진단됩니다.

identity("myString")처럼 문자열 리터럴을 바로 넘기면, T는 그 값 하나만 뜻하는 리터럴 타입 "myString"으로 추론됩니다. 이 결과를 const에 담으면 편집기에 "myString"이 보이고, let에 담으면 다시 넓은 string이 됩니다. 어느 쪽이든 그림처럼 같은 T가 입력과 반환을 잇는다는 점은 같고, 호출할 때마다 새 자바스크립트 함수가 생기는 것도 아닙니다.

여러 타입 매개변수

타입 매개변수는 쉼표로 구분해 여러 개 선언할 수 있습니다. 다음 pair는 서로 다른 두 타입의 값을 받아, 각 위치의 타입을 지킨 튜플로 돌려줍니다.

function pair<T1, T2>(arg1: T1, arg2: T2): [T1, T2] {
  return [arg1, arg2];
}

const myPair = pair("hello", 123);
console.log(myPair[0].toUpperCase(), myPair[1].toFixed(2));
HELLO 123.00

myPair의 타입은 [string, number]로 추론되므로, 첫 칸에는 문자열의 메서드를, 둘째 칸에는 숫자의 메서드를 쓸 수 있습니다. 반환 타입을 any[]로 적었다면 두 칸의 타입을 모두 잃어버렸을 것입니다.

제약 조건

타입 매개변수는 어떤 타입이든 받을 수 있으므로, 본문에서는 모든 타입에 공통인 연산만 쓸 수 있습니다. T의 값에서 length를 읽으려면, T가 length를 가진 타입이어야 한다는 제약 조건(constraint)을 extends로 붙입니다.

interface Lengthwise {
  length: number;
}

function getLengthWithConstraint<T extends Lengthwise>(arg: T): number {
  return arg.length;
}

console.log(getLengthWithConstraint("hello world"));
console.log(getLengthWithConstraint([1, 2, 3]));
console.log(getLengthWithConstraint({ length: 10, value: "test" }));
11
3
10

T extends Lengthwise는 "T는 length: number를 가진 타입이어야 한다"는 뜻이어서, 본문에서 arg.length를 쓸 수 있습니다. 문자열과 배열, length 속성을 가진 객체는 모두 이 조건을 만족하지만, getLengthWithConstraint(123)은 Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.로 진단됩니다. 제약은 정적인 모양만 요구하므로, length가 0 이상의 정수인지나 바깥에서 들어온 객체가 정말 이 모양인지는 검사하지 않습니다.

keyof와 인덱스 접근 타입

keyof T는 타입 T의 키들을 모은 타입입니다. { name: string; age: number; city: string }의 keyof는 "name" | "age" | "city"이며, 숫자 키나 인덱스 시그니처가 있으면 그것도 반영되므로 언제나 문자열 리터럴의 유니온인 것은 아닙니다. 이 keyof를 제약 조건에 쓰면, 객체에 실제로 있는 키만 받는 함수를 만들 수 있습니다.

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

const user = {
  name: "김철수",
  age: 30,
  city: "서울",
};

const userName = getProperty(user, "name");
const userAge = getProperty(user, "age");
console.log(userName, userAge);
김철수 30

K extends keyof T는 K가 user의 세 키 가운데 하나여야 한다는 뜻이고, 반환 타입 T[K]는 "T에서 키 K의 속성 타입"을 뜻하는 인덱스 접근 타입(indexed access type)입니다. 그래서 "name"을 넘기면 결과가 string, "age"를 넘기면 number가 됩니다. 없는 키를 넘긴 getProperty(user, "address")는 Argument of type '"address"' is not assignable to parameter of type '"name" | "age" | "city"'.로 진단됩니다.

user의 name·age·city 키에서 name을 선택하면 string, age를 선택하면 number가 됩니다. address는 허용 키가 아니며 런타임 존재 검사는 자동 생성되지 않습니다.

TYPE / RUNTIME / CONTRACT

선택한 키 K에 따라 결과 T[K]가 달라집니다

getProperty(user, "name")은 string, "age"는 number입니다. keyof는 키의 타입 영역이고 K extends keyof T가 허용 키를 제한합니다.

키와 결과 타입의 연결객체타입T→허용키K→결과T[K]의3노드2간선정적관계다. name선택string및age선택number관계를나타내며선택속성의undefined가능성과런타임검증은별도다.
객체 타입 Tname · age · city
연결 관계객체 타입 T · name · age · city → 허용 키 K · K extends keyof T
허용 키 KK extends keyof T
연결 관계허용 키 K · K extends keyof T → 결과 T[K] · 선택 속성의 타입
결과 T[K]선택 속성의 타입
키와 결과 타입의 연결 · 모바일객체타입T→허용키K→결과T[K]의3노드2간선정적관계다. name선택string및age선택number관계를나타내며선택속성의undefined가능성과런타임검증은별도다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.
객체 타입 Tname · age · city
연결 관계객체 타입 T · name · age · city → 허용 키 K · K extends keyof T
허용 키 KK extends keyof T
연결 관계허용 키 K · K extends keyof T → 결과 T[K] · 선택 속성의 타입
결과 T[K]선택 속성의 타입

ACTUAL EXAMPLE

본문 객체의 키와 값

name은 김철수, age는 30입니다. address 호출은 오류입니다. 객체와 키를 같은 T·K 계약으로 검사합니다.

KEY DOMAIN

문자열 키로만 한정되지 않습니다

명시적 숫자·symbol 키도 반영됩니다. string 인덱스 시그니처의 keyof는 JavaScript 키 변환 때문에 string | number가 될 수 있습니다.

INDEXED ACCESS

선택 속성의 타입을 보존

optional 속성 조회 타입에는 undefined가 포함됩니다. 여러 키의 유니온을 선택하면 결과도 해당 속성 타입의 유니온이 됩니다. 선택 속성은 본문의 member 확장 연습에서도 확인합니다. 여러 키의 유니온은 같은 조회 원리의 확장입니다.

RUNTIME

실제 키 존재 검사는 별도

obj[key] 조회 코드는 남지만 keyof·제약은 제거됩니다. 외부 입력의 객체 구조를 검증하는 코드는 이 예제에 없습니다.

키의 정적 허용 여부와 실제 객체에 대한 런타임 검증을 구분합니다.

위 그림처럼 객체의 타입 T에서 허용되는 키 K가 정해지고, 고른 키에 따라 결과 T[K]가 정해집니다. 이것은 모두 정적인 관계여서, 실행 중에 객체에 그 키가 정말 있는지 확인하는 코드는 만들어지지 않습니다. 선택적 속성의 키를 고르면 결과에 undefined가 포함된다는 점도 연습 문제에서 확인합니다.

값이 없는 경우를 반환 타입에 넣기

이제 처음의 마지막 요소 함수를 만들어 봅니다. 요소의 타입 T를 결과의 타입과 잇되, 빈 배열에서는 돌려줄 요소가 없으므로 결과에 undefined를 더합니다. 읽기만 하므로 매개변수를 readonly T[]로 적으면, 바꿀 수 없는 배열도 받을 수 있습니다.

function last<T>(values: readonly T[]): T | undefined {
  return values[values.length - 1];
}

const latest = last([10, 20]);
if (latest !== undefined) {
  console.log(latest.toFixed(0));
}
console.log(last<string>([]));
20
undefined

last([10, 20])에서 T는 number로 추론되지만, 그렇다고 배열이 비어 있지 않다는 조건까지 생기지는 않습니다. 그래서 latest의 타입은 number | undefined이고, undefined가 아닌지 확인한 뒤에야 숫자의 메서드를 씁니다. 빈 배열 []에서는 T를 추론할 단서가 없으므로 last<string>([])처럼 타입 인수를 적었고, 마지막 위치에 요소가 없어 undefined가 출력됩니다. 입력과 출력의 관계를 타입 매개변수로 나타내는 방법은 공식 Handbook의 제네릭에 이어집니다.

더 깊이: 추론을 조절하는 const 타입 매개변수와 NoInfer

컴파일러는 객체나 배열을 넘긴 호출에서 대개 나중에 값을 바꿀 수 있도록 넓은 타입을 추론합니다. 그래서 { names: ["Alice", "Bob", "Eve"] }를 넘기면 names는 string[]이 되고, 정확한 값을 타입으로 받으려면 호출하는 쪽이 as const를 붙여야 했습니다. TypeScript 5.0부터는 타입 매개변수 앞에 const를 붙여 <const T extends HasNames>처럼 선언하면, 호출하는 쪽이 아무것도 붙이지 않아도 readonly ["Alice", "Bob", "Eve"]처럼 as const를 붙인 것과 같은 추론이 기본이 됩니다. 이 수정자는 호출식 안에 직접 쓴 객체와 배열, 원시 값의 추론에만 영향을 주므로, 미리 변수에 담아 둔 배열을 넘기면 효과가 없습니다. 또 제약을 string[]처럼 변경할 수 있는 배열로 적으면 5.2까지는 추론한 읽기 전용 튜플이 제약에 맞지 않아 추론이 제약 쪽으로 돌아갔지만, 5.3부터는 제약을 어기지 않는 범위에서 추론하므로 읽기 전용이 아닌 튜플 ["Alice", "Bob", "Eve"]가 나오고 readonly string[] 제약에서만 읽기 전용 튜플이 나옵니다.

반대로 어떤 매개변수가 추론에 끼어들지 않게 해야 할 때도 있습니다. createStreetLight<C extends string>(colors: C[], defaultColor?: C)에 ["red", "yellow", "green"]과 "blue"를 넘기면, 컴파일러는 "blue"도 C의 후보로 삼아 C를 네 색의 유니온으로 추론하고 호출을 통과시킵니다. TypeScript 5.4에 들어온 유틸리티 타입 NoInfer<T>로 defaultColor?: NoInfer<C>라고 적으면, 그 자리는 추론의 후보를 찾는 데 쓰이지 않으므로 C는 세 색으로 정해지고 "blue"는 진단됩니다. 두 기능 모두 TypeScript 5.0과 5.4의 릴리스 노트에 예제와 함께 설명되어 있습니다.

정리

  • 제네릭 함수는 타입 매개변수 T로 입력과 반환의 타입을 이어서, any와 달리 호출한 쪽이 결과의 타입을 잃지 않게 합니다.
  • 타입 인수는 꺾쇠괄호로 직접 넘길 수 있지만 대부분 인수에서 추론되며, 문자열 리터럴을 바로 넘기면 T가 리터럴 타입으로 추론되기도 합니다.
  • 타입 매개변수는 여러 개 선언할 수 있고, extends로 제약 조건을 붙이면 본문에서 그 모양의 속성을 쓸 수 있습니다.
  • K extends keyof T는 객체에 있는 키만 받게 하고, 인덱스 접근 타입 T[K]는 고른 키의 속성 타입을 결과로 돌려줍니다.
  • 제네릭의 관계와 제약은 모두 정적인 검사이므로, 빈 배열이나 선택적 속성처럼 값이 없을 수 있는 경우는 반환 타입에 undefined로 드러내고 확인한 뒤에 씁니다.

연습 문제

1. 계절 배열의 마지막 요소

last(["봄", "여름"])의 결과 타입과 실제 값은 무엇인가요? 빈 문자열 배열을 넘긴 결과에 확인 없이 바로 .toUpperCase()를 부를 수 있나요?

해설 보기

T가 string으로 추론되므로 결과 타입은 string | undefined이고, 실제 값은 마지막 요소인 "여름"입니다. 빈 배열에서는 결과가 undefined일 수 있으므로, 확인 없이 .toUpperCase()를 부르면 결과가 undefined일 수 있다는 진단이 나옵니다. 먼저 undefined가 아닌지 확인해 string으로 좁힌 뒤에 문자열의 메서드를 씁니다.

2. 선택적 속성 읽기

다음 코드에서 nickname의 타입과 출력은 무엇인가요? 주석을 풀면 어떻게 되나요?

const member: { name: string; nickname?: string } = { name: "김철수" };
const nickname = getProperty(member, "nickname");
console.log(nickname);
// nickname.toUpperCase();
해설 보기

"nickname"은 member의 타입에 있는 키이므로 검사를 통과하고, 선택적 속성이어서 nickname의 타입은 string | undefined입니다. 실제 member에는 값을 넣지 않았으므로 출력은 undefined입니다. 주석을 풀면 'nickname' is possibly 'undefined'.라는 TS18048이 나오므로, keyof가 키를 허용한다는 것과 그 값이 언제나 있다는 것은 서로 다른 조건입니다.

3. any와 제네릭의 차이

const count: number = 123;일 때 identityAny(count).length와 identity(count).length는 각각 검사와 실행에서 어떻게 되나요?

해설 보기

identityAny(count)의 결과는 any이므로 .length는 검사를 통과하고, 실행하면 숫자에 length가 없어 undefined가 됩니다. identity(count)는 T가 number로 추론되어 결과도 number이므로, .length는 Property 'length' does not exist on type 'number'.로 진단되어 실행 전에 실수를 알 수 있습니다.

다음 글 화살표 함수와 this에서는 함수 안의 this가 호출 방식에 따라 무엇을 가리키는지와, 화살표 함수와 this 매개변수로 그 값을 정하는 방법을 다룹니다.