한 입 크기로 잘라먹는 타입스크립트를 보고 정리한 내용입니다.
function func(value: any) {
return value;
}
let num = func(10);
let bool = func(true);
let str = func("string");
위와 같은 코드를 작성하면 num
, bool
, str
의 타입은 모두 any입니다.
함수는 리턴값을 기준으로 타입을 추론하기 때문입니다.
num.toUpperCase();
하지만 num
이 any 타입으로 추론되기 때문에 String 메서드인 toUpperCase
를 사용해도 오류를 발생시키지 않습니다.
function func(value: unknown) {
return value;
}
let num = func(10);
num.toUpperCase(); // 오류!
num.toFixed(); // 오류!
unknown
타입으로 정의하면 toUpperCase
메서드 사용 시 오류를 발생 시킬 수 있지만, toFixed
와 같은 Number 메서드도 오류를 발생 시키는 문제가 있습니다.
function func<T>(value: T): T {
return value;
}
이때 func
함수를 제네릭 함수로 만들어 인자에 따라 반환 타입을 가변적으로 정해줄 수 있습니다.
T
는 타입을 저장하는 타입 변수입니다.
타입 변수는 인자로 어떤 타입을 전달하는지에 따라 저장되는 타입이 달라지며, 타입 파라미터, 제네릭 타입 변수, 제네릭 타입 파라미터 등으로도 불립니다.
let num = func(10);
let bool = func(true);
let str = func("string");
제네릭 함수로 변환하면 num
, bool
, str
의 타입은 number
, boolean
, string
으로 정의됩니다.
타입 변수 T
는 자바스크립트의 변수처럼 상황에 따라 다른 타입을 담을 수 있습니다.
타입스크립트는 함수 호출 시 전달된 인자의 타입을 기준으로 타입 변수의 타입을 추론합니다.
let arr = func([1, 2, 3]);
number[]
타입을 갖는 값을 인자로 전달하면 타입 변수에 number[]
이 담겨 arr
의 타입은 number[]
가 되는데요.
이때 arr
의 타입을 tuple
로 정의하고 싶다면 어떻게 해야 할까요?
// 타입 단언
let arr = func([1, 2, 3] as [number, number, number]);
// 또는 직접 작성
let arr = func<[number, number, number]>([1, 2, 3]);
타입 단언을 사용해도 되지만 함수 호출할 때 타입 변수 T
에 할당해주고 싶은 타입을 직접 작성하여 해결할 수 있습니다.
function swap<T>(a: T, b: T) {
return [b, a];
}
const [a, b] = swap("1", 2); // 오류!
오류가 발생하는 이유는 첫 번째 인자로 string 타입의 값을 전달하면 타입 변수에 string 타입이 할당되면서 두 번째 인자도 string 타입이 할당되기 때문입니다.
function swap<T, U>(a: T, b: U) {
return [b, a];
}
이 문제는 타입 변수를 하나 더 선언해줌으로써 해결할 수 있습니다.
타입 변수는 하나만 선언할 필요없이, 여러 개 선언할 수 있습니다.
function returnFirstValue<T>(data: T[]) {
return data[0];
}
let num = returnFirstValue([0, 1, 2]);
let str = returnFirstValue(["hello", "mynameis"]);
위 코드에서 data: T[]
이 아닌 data: T
로 작성하면 오류가 발생합니다.
함수 내부에서는 호출되어 타입 변수의 타입이 결정되기 전에 최대한 오류를 발생시키지 않기 위해 unknown으로 추론합니다.
따라서 unknown 타입의 값에 배열 인덱스를 사용했기 때문에 오류가 발생합니다.
data: T[]
로 선언해주면 어떤 배열이 오든 인덱스 접근이 가능하기 때문에 오류가 사라지고 num
과 str
는 number
와 string
타입으로 추론합니다.
let str = returnFristValue([1, "hello", "mynameis"]);
만약 위와 같이 작성하게 된다면 number | string
으로 추론하는데요.
function returnFirstValue<T>(data: [T, ...unknown[]]) {
return data[0];
}
만약 배열의 첫 번째 인덱스 타입만을 할당받고 싶다면 인자의 타입을 [T, …unknown[]]
로 바꿔 받아 해결할 수 있습니다.
function getLength<T extends { length: number }>(data: T) {
return data.length;
}
만약 length
와 같이 특정 속성이 있는 값만 인자로 받는 함수를 만들고 싶다면 extends
을 이용할 수 있습니다.
const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2);
위와 같은 상황일 때 map
메서드에서 콜백 함수의 it
는 number 타입으로 추론합니다.
인자의 타입을 추론할 수 있는 이유는 map
메서드의 타입이 어딘가에 별도로 선언되어 있기 때문입니다.
lib.es5.d.ts
라는 자바스크립트 내장 함수의 타입들이 선언된 파일이 있습니다.
여기서 map
메서드의 타입을 볼 수 있는데요.
꽤 복잡해보이는 map
메서드의 타입을 직접 구현해보도록 하겠습니다.
function map<T>(arr: T[], callback: (item: T) => T) {
let result = [];
for (let i = 0; i < arr.length; i++){
result.push(callback(arr[i]));
}
return result;
}
Array.prototype.map()
은 이미 선언되어 있기 때문에 별도로 메서드 타입을 정의하기 위해 map
함수를 만들어줍니다.
map(arr, (it) => it * 2);
map("hi", "hello"], (it) => it.toUpperCase());
map(["hi", "hello"], (it) => parseInt(it)); // 오류!
오류가 발생하는 이유는 콜백 함수의 반환 타입이 number가 되기 때문입니다.
하지만 실제 map
메서드는 string 타입의 배열을 인자로 넘겨도 결과가 반드시 string 배열 타입은 아닙니다.
function map<T, U>(arr: T[], callback: (item: T) => U) {
let result = [];
for(let i = 0; i < arr.length; i++){
result.push(callback(arr[i]));
}
return result;
}
map(["hi", "hello"], (it) => parseInt(it));
이럴 때는 타입 변수를 추가해줍니다.
arr
에 string[] 타입이 들어오고 item
타입도 string이 됩니다.
콜백 함수의 반환값은 number 타입이기 때문에 U
의 타입을 이때 추론하여 number 타입이 들어오게 됩니다.
interface KeyPair<K, V> {
key: K;
value: V;
};
let keyPair: KeyPair<string, number> = {
key: "key",
value: 0,
};
제네릭 인터페이스를 사용할 때에는 반드시 제네릭 타입 인자를 명시해야 합니다.
제네릭 인터페이스는 특히 객체의 인덱스 시그니처 문법과 함께 사용하면 유연한 객체 타입을 만들 수 있습니다.
interface Map<V> {
[key: string]: V;
};
let stringMap: Map<string> = {
key: "value",
};
let booleanMap: Map<boolean> = {
key: true,
};
인덱스 시그니처와 제네릭 인터페이스를 사용해서 만든 Map 타입은 다양한 객체를 표현할 수 있습니다.
type Map2<V> = {
[key: string]: V;
}
let stringMap2: Map2<string> = {
key: "hello",
};
제네릭 타입 별칭을 만드는 방법은 인터페이스와 거의 비슷합니다.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
interface User {
name: string;
profile: Student | Developer;
}
function goToSchool(user: User) {
if(user.profile.type !== "student"){
console.log("잘 못 오셨습니다.");
return;
}
const school = user.profile.school;
console.log(`${school}로 등교 완료`);
}
const developerUser: User = {
name: "장원정",
profile: {
type: "developer",
skill: "TypeScript",
},
}
const studentUser: User = {
name: "홍길동",
profile: {
type: "student",
school: "신성중학교",
},
}
goToSchool
함수는 User 타입을 인자로 받아서 profile.type
이 student일 때만 등교 완료를 출력하는 함수입니다.
User의 구분이 많아지고 특정 회원만 이용할 수 있는 함수가 많아지면 함수를 만들 때마다 타입 좁히기를 사용해야 하기 때문에 확장성이 좋지 않습니다.
이럴 때 제네릭 인터페이스를 사용하면 깔끔하게 코드를 작성할 수 있습니다.
interface Student {
type: "student";
school: string;
}
interface Developer {
type: "developer";
skill: string;
}
interface User<T> {
name: string;
profile: T;
}
function goToSchool(user: User<Student>) {
const school = user.profile.school;
console.log(`${school}로 등교 완료!`);
}
const developerUser: User<Developer> = {
name: "장원정",
profile: {
type: "developer",
skill: "TypeScript",
},
}
const studentUser: User<Student> = {
name: "홍길동",
profile: {
type: "student",
school: "신성중학교",
}
}
goToSchool(developerUser); // 오류!
User 인터페이스를 제네릭 인터페이스로 바꾸고, profile
의 타입으로 제네릭 타입 매개 변수를 사용합니다.
이렇게 수정하면 goToSchool
함수에서 타입 좁히기 코드없앨 수 있습니다.
복잡한 객체 타입을 정의하여 사용할 때, 제네릭 인터페이스를 활용하면 코드와 타입 정의를 깔끔하게 분리할 수 있어 유용합니다.
class NumberList {
constructor(private list: number[]) {}
push(data: number) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new NumberList([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print(); // [1, 2, 4]
위 코드에서 추가로 StringList
클래스도 필요한 상황이라면, 어떻게 확장하면 좋을까요?
class List<T> {
constructor(private list: T[]) {}
push(data: T) {
this.list.push(data);
}
pop() {
return this.list.pop();
}
print() {
console.log(this.list);
}
}
const numberList = new List([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print(); // [1, 2, 4]
const stringList = new List(["1", "2"]);
기존의 NumberList
클래스는 타입을 모두 number로 고정해놔서 확장성이 좋지 않습니다.
List
라는 제네릭 클래스를 제네릭 클래스로 만들면, 생성자 함수에 들어오는 타입을 타입 변수에 할당하여 인자로 전달하는 배열 타입에 대응할 수 있습니다.
제네릭 클래스는 제네릭 인터페이스와 제네릭 타입 별칭과는 다르게 클래스의 생성자를 호출할 때 생성자의 인자로 전달하는 값을 기준으로 타입을 추론하기 때문에 반드시 타입 명시를 해주지 않아도 됩니다.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(20);
}, 3000);
});
promise.then((response) => {
console.log(response); // 20;
});
위 코드에서 console.log(response * 10);
으로 수정하면 오류가 발생하는데요.
왜냐하면 response
는 unknown 타입으로 추론하기 때문입니다.
Promise는 resolve로 전달되는 값의 타입을 명시하지 않으면 기본적으로 unknown으로 추론합니다.
const promise = new Promise<number>((resolve, reject) => {
setTimeout(() => {
resolve(20);
}, 3000);
});
promise.then((response) => {
console.log(response * 10);
});
자바스크립트의 내장 클래스인 Promise는 타입스크립트에서 제네릭 클래스로 타입이 선언되어 있습니다.
생성자 함수를 호출할 때 비동기 작업의 결과값 타입을 제네릭 타입 인자로 명시해주면 response
의 타입이 해당 타입으로 추론되고 resolve
함수도 반드시 그 타입으로만 받을 수 있게 바뀝니다.
const promise = new Promise<number>((resolve, reject) => {
setTimeout(() => {
reject("~~ 때문에 실패");
}, 3000);
}
promise.catch((err) => {
if(typeof err === "string") {
console.log(err);
}
});
reject
함수는 내부적으로 reject: (reason?:any) => void
로 정의되어 있습니다.
catch
의 인자 err
의 타입도 any
로 추론합니다.
따라서 인자의 타입을 정확히 알 수 없기 때문에 프로젝트의 상황에 맞게 타입 좁히기를 사용해야 합니다.
interface Post {
id: number;
title: string;
content: string;
}
function fetchPost() {
return new Promise<Post>((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 컨텐츠",
});
}, 3000);
});
}
const postRequest = fetchPost();
postRequest.then(post => post.id);
Promise를 반환하는 함수의 타입을 정의할 때는 return
문의 Promise 생성자 함수에서 제네릭 타입 인자를 명시하거나,
function fetchPost(): Promise<Post> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
title: "게시글 제목",
content: "게시글 컨텐츠",
});
}, 3000);
});
}
fetchPost
함수의 반환 타입으로 Promise<Post>
를 작성해줄 수도 있습니다.
후자가 함수의 인터페이스만 봐도 반환 타입을 알 수 있기 때문에 가독성 면에서 더 좋습니다.
제네릭은 사용하기에도 읽기에도 어려운것 같습니다... 🥹 오픈소스에서는 항상 사용되고 있어서 제네릭이 익숙해진다면 오픈소스 코드를 파악하는데 도움이 될 것 같아 익히려고 노력하고있는데 쉽지 않네요... 글 잘 봤습니다 ㅎㅎ!!
제네릭은 처음 공부할 때도 어려웠지만, 알겠다 싶다가도 막상 구성하면 헷갈리더라구요.
특히 라이브러리 사용할 때, 구성한 제네릭이 결국 받지 못하게 되어 있어, 열심히 구성하다 롤백한 경험이 떠오르네요 ;ㅅ;
타입스크립트의 첫번째 관문이라고 생각하는데, 공유해주신 내용으로 시작해서 타입스크립트를 더 공부해 봐야겠습니다.
좋은 글 감사합니다!!
요즘 타입챌린지 할 때 제일 고민해보는 부분이 제네릭인데, 주제 다루어주셔서 반갑습니다.
제네릭을 실제 사용할 때 저는 잘써야 편리하고 가독성이 좋아진다고 생각하는데, 원정님께서는 어떤 상황에 제네릭을 고민하시는지 알고싶습니다!