...
우리가 자바스크립트에서 많이 쓰이는 forEach() 나 map() 같은 고차함수는 타입스크립트에서도 당연히 사용할 수 있다.
하지만 타입스크립트에선 각 인수 마다 타입을 지정해주어야 돌아가는데, 기존의 자바스크립트 메소드에 어떻게 타입을 설정하였기에 타입스크립트에서도 고차함수에서 사용할 수 있는 것일까?
실제로 제네릭으로 타입이 지정이 되있어서 타입스크립트에서도 무리없이 사용이 가능한 것이다.
지금부터 실제로 타입스크립트에 설정되어있는 기존의 자바스크립트 고차함수가 제네릭으로 어떤식으로 설정되어있는지 하나하나 분석하면서 확실히 제네릭을 이해해보는 시간을 가져보자.
실전 라이브러리 제네릭 타입 분석하기
forEach 타입 분석하기
가장 많이 쓰이는 forEach 의 제네릭을 분석해보자.
다음 코드를 타입스크립트 파일에 적고, ctrl을 누른 상태로 forEach 단어를 클릭해보자.
[1, 2, 3].forEach((value, index, arr) => {});
그러면 lib.es5.d.ts 파일로 이동이 되는데, 여기에 forEach 함수가 인터페이스에 함수 자체 타입이 지정되어 있음을 알수 있다.
이처럼 타입스크립트가 기본적으로 자바스크립트 코드에 대한 타입들을 일일히 지정해 놓았기 때문에, .ts 파일에서 무리없이 자바스크립트 메소드들을 사용할 수 있는 것이다.
위의 forEach 함수 타입 부분을 그대로 복사해와 다음과 같이 인터페이스를 생성해주고 안에다 넣어줘보자.
그리고 자바스크립트의 형태의 forEach 콜백 함수 형태와 함수 구조 타입 형태와 같다는 걸 볼 수 있다.
interface Array<T> {
forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
}
[1, 2, 3].forEach((item, index, arr) => {
console.log(item); // 1, 2, 3
});
여기서 제네릭 T 는 value와 array 매개변수에만 쓰였다. (index는 당연히 숫자이니 number로 되어 있다)
저부분을 제네릭으로 타입을 지정함으로써 우리는 forEach 고차함수를 다양한 배열 형태에서 사용할 수 있었던 것이다.
이처럼, 제네릭 타입의 value 매개변수가 호출된 데이터의 타입에 따라 제네릭 타입이 결정되어 타입 추론이 됨을 확인 할 수 있다
사실 호출 구문을 좀더 상세하게 풀어보면 이렇게 되는 것이다.
interface Array<T> {
forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
}
const n: Array<number> = [1, 2, 3]; // 인터페이스의 제네릭을 number 타입으로 선언
n.forEach((value, index, arr) => {
console.log(value);
});
이처럼 코드를 작성할 때는 타입이 뭐가 될지 모르는데, 코드를 실행할때는 확실히 타입을 알때는 그 자리를 제네릭으로 엮어두는 식으로 타입스크립트 코딩을 하면 된다.
map 타입 분석하기
forEach 다음으로 많이 사용되는 map 고차함수도 이런식으로 함수 타입이 되어 있다.
interface Array<T> {
forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
}
const strings = [1, 2, 3].map((value) => value.toString()); // ['1', '2', '3']
앞서 배운대로 map 함수 제네릭을 차근차근 추론 하면 된다.
실전 라이브러리 제네릭 타입 만들어보기
지금 까지 미리 정의되어 있는 forEach 함수 타입을 가져와 분석해보며 정말로 제네릭이 적절하게 정의 되어 있는지 살펴보았다.
이번에는 거꾸로 우리가 forEach 함수 타입을 제네릭으로 만들어 보는 시간을 가져보자.
둘중에 하나만 할 줄 알면 반쪽짜리 타입스크립트 공부이기 때문에, 이러한 타입 연습은 정말 필요한 과정이다.
다만 난이도가 쉽지 않은 편이기에 눈에 힘을 빡 주고 따라오길 바란다.
진정한 타입스크립트 코딩은 남의 라이브러리의 속성을 가져와 타입 지정을 통해 내 환경에서도 이용할 수 있게 만드는 것이다.
forEach 타입 만들기
다음 코드는 arr 변수의 타입 Arr 에서 배열을 받아 고차함수로 forEach() 문을 사용하는 예제이다.
지금은 당연히 에러가 뜨게 되는데 왜냐하면 인터페이스 Arr에 forEach 함수에 대해서 함수 자체 타입이 선언 되어 있지 않기 때문이다.
interface Arr {}
const arr: Arr = [1, 2, 3]; // 숫자 배열
const arr2: Arr = ['1', '2', '3']; // 문자열 배열
arr.forEach((value) => {
value.toFixed();
console.log(value);
});
arr.forEach((value) => {
return 3;
});
arr2.forEach((value) => {
value.charAt(3);
console.log(value);
});
arr2.forEach((value) => {
return 3;
});
커스텀 배열 인터페이스인 Arr를 차근차근 채워보자.
가장 먼저 forEach 파라미터를 정의한다. 함수 형태이니 forEach() : 형태로 적고, 반환값은 우선 기본으로 void 라고 적어주자.
interface Arr {
forEach(): void;
}
forEach는 고차함수이다. 따라서 괄호 () 안에 callback: () => void 라는 콜백 함수 자체 타입을 선언해준다.
interface Arr {
forEach(callback: () => void): void;
}
그리고 이 콜백 함수안에는 value 라는 매개변수가 들어가 사용된다.
코드에서 정의 한 arr 과 arr2 는 각각 넘버 배열과 문자열 배열이니 타입을 number | string 으로 적어준다.
interface Arr {
forEach(callback: (value: number | string) => void): void;
}
대부분 빨간줄이 사라졌지만, 이번엔 value 아규먼트에 선언한 유니온 타입이 문제다.
이렇기 때문에 바로 제네릭을 쓰는 것이다 (+1)
인터페이스에 제네릭을 선언해주고 value 아규먼트에서 제네릭 타입을 써준다.
interface Arr<T> {
forEach(callback: (value: T) => void): void;
}
const arr: Arr<number> = [1, 2, 3];
const arr2: Arr<string> = ['1', '2', '3'];
빨간줄이 완벽하게 제거 되었다.
이번엔 forEach 고차함수의 매개변수를 모두 사용할 일이 생겼다고 해보자.
arr2.forEach((value, index, array) => {
return 3;
});
인터페이스 Arr 에서 따로 index 와 array 에 대한 아규먼트 타입을 정의 안해줬으니 당연히 빨간줄이 또 발생하게 된다.
해당 아규먼트를 인터페이스 함수 자체 타입에 선언해주면 간단히 해결된다.
interface Arr<T> {
forEach(callback: (value: T, index: number, array: T[]) => void): void;
}
const arr: Arr<number> = [1, 2, 3];
const arr2: Arr<string> = ['1', '2', '3'];
arr2.forEach((value, index, array) => {
return 3;
});
실제 Array 인터페이스에 정의되어 있는 forEach 함수 타입과 비교해보면, this를 안썼으니 제외하면, 구조가 똑같음을 볼 수 있다.
이렇게 직접 타입을 추론하여 실제로 forEach 함수 타입을 만들어 보았다.
map 타입 만들기
이번엔 난이도를 높여서 map 고차함수도 타입 추론으로 함수 타입을 정의해 보자.
interface Arr<T> {
forEach(callback: (value: T, index: number, array: T[]) => void): void;
}
const arr: Arr<number> = [1, 2, 3];
const a = arr.map((value) => {
return value + 10;
}); // [10, 20, 30]
const b = arr.map((value) => {
value += 10;
return value.toString();
}); // ['10', '20', '30']
가장 먼저 map() 이라는 함수 속성을 정의한다.
map() 함수 괄호 안에 callback 속성과 value 아규먼트 속성을 각각 정의해 준다.
그리고 기본적으로 map() 함수는 리턴값을 모아 배열로 반환하기 때문에 최종 제네릭 형태는 다음과 같이 된다.
interface Arr<T> {
forEach(callback: (value: T, index: number, array: T[]) => void): void;
map(callback: (value: T) => T): T[];
}
하지만 간과하지 못한 부분이 생겼다.
제네릭 T 가 아규먼트 타입과 반환 타입이 같은경우, 위와 같이 문자열 형태로 반환하여 문자열 배열로 만드는 로직일 경우 타입이 잘못되게 된다.
이 현상은 제네릭 T 를 한개만 선언해서 사용해서 그렇다.
제네릭 변수는 얼마든지 추가해줄 수 있다. S 라는 제네릭을 하나 더 추가해주자.
그런데 이번에는 인터페이스명 옆에 추가해주는게 아닌 map에 추가해 준다. 왜냐하면 인터페이스 제네릭 쪽에 추가해주면, 배열 변수를 선언할때 제네릭 타입을 2개 받아야 되기 때문이다.
어쨋든 최종적인 형태는 다음과 같이 된다.
핵심은 제네릭을 한개만 쓸 경우 여러 타입을 커버하지 못할경우 제네릭 타입 파라미터를 하나 더 써서 모든 타입을 커버 할 수 있게 만드는 것이다.
interface Arr<T> {
forEach(callback: (value: T, index: number, array: T[]) => void): void;
map<S>(callback: (value: T) => S): S[];
}
const arr: Arr<number> = [1, 2, 3];
const a = arr.map((value) => {
return value + 10;
}); // [10, 20, 30]
const b = arr.map((value) => {
value += 10;
return value.toString();
}); // ['10', '20', '30']
이렇게 제네릭을 잘 선언해주면, 나중에 다른 타입이 들어와도 커버가 된다.
const c = arr.map((value) => {
return value % 2 === 0;
}); // [false, true, false]
실제 타입스크립트 map 함수 타입 정의를 봐도 크게 다르지 않음을 볼수 있다.
# 참고자료
제로초 타입스크립트 올인원
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.