함수에 적용할 수 있는 타입에 대해 알아보자.
객체만의 타입을 만들고 싶을 때, 이렇게 별칭 타입을 만들어서 객체만의 타입을 지정할 수 있다.
type Player = {
name: string,
age: number
}
const rm: Player = {
name: "rm",
age: 28
}
함수만의 타입을 만들고 싶다면 어떻게 하면 될까?
const add = (a:number, b: number) => a+b
콜 시그니처
란 함수 위에 마우스를 올렸을 때 보게 되는 것을 말한다.
(a: number, b: number) => number
이 부분이 콜 시그니처이다.
콜 시그니처는 타입스크립트에게 이 함수가 어떻게 호출되는지 설명해주는 부분이다.
파라미터의 타입이 무엇인지, 그리고 함수의 반환 타입이 무엇인지 말이다.
함수가 어떻게 구현되는지 알려주는 것이 아니다. 함수 인자(arguments)의 타입과 함수의 반환 값이 가지는 타입을 알려준다.
//나만의 함수 콜 시그니처 선언
type Add = (a: number, b:number) => number;
//함수 타입에 콜 시그니처 명시
const add: Add = (a, b) => a+b;
이렇게 하면 직접 add 함수의 인자에 타입을 명시할 필요가 없어지기 때문에, 타입 명시하는 코드를 분리해서 구현할 수 있다.
리액트로 props로 함수를 보낼 때 타입스크립트에게 함수가 어떻게 작동하는지 설명해줘야 하는데, 그럴 때 많이 사용한다.
function overloading
이나 method overloading
이라고도 부른다.
실제로 많은 오버로딩된 함수를 직접 작성하지는 않는다. 그대신 대부분 다른 사름들이 만들어 둔 외부 라이브러리를 사용하는데, 이런 패키지나 라이브러리들은 오버로딩을 엄청 많이 사용한다.
그래서 오버로딩이 어떻게 생겼는지 알아두자 ^~^!
//나만의 함수 콜 시그니처 선언
type Add = (a: number, b:number) => number;
//(a: number, a:number) => number;
//1. 이는 단축키 같은 것으로, 콜 시그니처를 만드는 가장 간단하고 빠른 지름길이다.
//2. 콜 시그니처를 좀 더 길게 작성할 수도 있다.
type Add = {
(a: number, b: number) : number;
}
콜 시그니처를 좀 더 길게 작성하는 두 번째 방법이 존재하는 이유는 오버로딩 때문이다.
오버로딩
은 함수가 여러개의 콜 시그니처를 가지고 있을 때 발생한다.//나만의 함수 콜 시그니처 여러개 선언
type Add = {
(a: number, b: number) : number;
(a: number, b: string) : number;
}
const add: Add = (a, b) => a+b;
add 함수는 Add 타입을 가지는데, 이 Add 타입은
(a: number, b: number) : number;
이 모양으로 부를 수도 있고
(a: number, b: string) : number;
이 모양으로 불르 수도 있게 된다.
그러면 즉시 타입스크립트는 잘못되었다는 것을 알게 된다.
왜냐하면 a는 항상 number이기 때문에 number + string은 있을 수 없는 일이기 때문이다.
b가 string
도 될 수 있고, number
도 될 수 있기 때문에 string과 number는 더할 수 없다고 오류가 뜬다.
//나만의 함수 콜 시그니처 여러개 선언
type Add = {
(a: number, b: number) : number;
(a: number, b: string) : number;
}
const add: Add = (a, b) => {
if (typeof b === "string") return a; //b가 string이면 a만 리턴
return a+b; //b가 number면 a+b 리턴
}
//이제 오류 안뜸
별로 좋은 예시는 아니다. 왜냐하면 매우 소수의 함수만 이런식으로 작동하기 때문이다.
하지만 오버로딩의 핵심을 보여주는 좋은 예시였음!
일부 라이브러리에는 몇 가가지 함수가 있는데, string을 보내거나 configuration 객체를 보낼 수 있게 허용하는 함수가 있을 수 있다.
예를 들면, NextJS에는 라우터
를 가지는데, 페이지를 바꾸고 싶을 때 이런식으로 사용한다.
//1. string 값으로 "/home"을 주면 홈으로 이동
Router.push("/home");
//2. 갹체 안에 path를 설정하여 홈으로 이동하게 할 수도 있음
Router.push({
path: "/home",
state: 1, //추가로 다른 것도 넣을 수 있음
});
완벽한 오버로딩의 예시이다.
type Config = {
path: string,
state: number
}
//오버로딩: 콜 시그니처 여러 개 사용
type Push = {
(path: string):void //path 인자로 string를 받고 아무것도 리턴하지 않는 함수
(config: Config):void //config 인자로 객체를 받고 아무것도 리턴하지 않는 함수
}
//오버로딩 사용하기
const push: Push = (config) => {
//config 인자로는 string이나 Config 객체 타입을 받을 수 있으므로 체크해야 함
if(typeof config === "string"){
console.log(config); //이 스코프에서는 config가 스트링인게 확실
} else {
console.log(config.path);// 이 스코프에서는 config가 Config객체임이 확실
}
}
패키지나 라이브러리를 디자인할 때, 많은 사람들이 이런 식으로 오버로딩을 사용한다.
위의 예시는 인자의 개수는 같고 타입이 달랐다.
다른 여러 개의 argument를 가지고 있을때 발생하는 일을 알아보자.
예를 들어 하나의 콜 시그니처는 2개의 파라미터를 가지는데, 다른 콜 시그니처는 파라미터를 3개를 가진다고 해보자.
//파라미터 개수가 다른 콜 시그니처를 여러개 가질 때
type Add = {
(a: number, b: number) : number;
(a: number, b: number, c: number ) : number;
}
const add: Add = (a, b, c) => {
return a + b
}
//모든 콜 시그니처에 c가 있는게 아니기 때문에 오류 뜸!
//파라미터 개수가 다른 콜 시그니처를 여러개 가질 때
type Add = {
(a: number, b: number) : number;
(a: number, b: number, c: number ) : number;
}
//Add를 부를 때, a와 b를 부르거나, 또는 abc를 부를 수 있도록 c는 옵셔널 하게 하면 된다.
//따라서 c는 아마도 number 일 것이라고 알려주면 된다.
const add: Add = (a, b, c?:number) => {
if(c) return a + b + c //c가 있다면 a+b+c
return a + b //c가 없다면 a+b
}
//그러면 오류 안뜸!
add(1,2); //3
add(1,2,3); //6
이런 유형은 그렇게 자주 보는 유형은 아닐 거라고 한다.
poly
는 many, several, much, multi의 뜻을 가지고 있다.morphos
혹은 morphic
은 form(형태), stuructre(구조)라는 뜻을 가진다.따라서 둘을 합치면, many structure(여러가지 다른 많은 구조, 모양들)가 된다.
기본적으로 함수는 여러가지 다른 모양 혹은 형태를 가지고 있다.
숫자로 이루어진 배열을 받고, 그 배열의 요소를 하나씩 print해주는 함수를 만들어보자.
//number로 이루어진 배열 받아서 하나씩 출력
//string으로 이루어진 배열 받아서 하나씩 출력
//boolean으로 이루어진 배열 받아서 하나씩 출력
type SuperPrint = {
(arr: number[]) : void
(arr: string[]) : void
(arr: boolean[]) : void
}
const superPrint: SuperPrint = (arr) => {
arr.forEach(i => console.log(i))
}
superPrint([1,2,3]);
superPrint(["a","b","c"]);
superPring([true, false]);
위의 콜 시그니처에 작성한 타입은 모두 concrete type
이다.
concrete type
에는 string
, number
, boolean
, void
, unknown
등이 있다.이렇게 계속 콜 시그니처를 늘리는 대신, 타입스크립트에게 generic 타입
을 받을 거라고 알려주면 좀 더 유연하게 어떤 타입이든 받을 수 있고 그 받은 타입에 따라 타입을 추론 할 수 있다.
type SuperPrint = {
<T>(arr: T[]) : void
}
argument가 제네릭을 받는 다고 TS에게 알려줘야 한다.
이렇게 <T>
꺽쇠에 식별자를 넣어서 이 콜 시그니처가 제네릭을 받는 다고 알려줄 수 있다.
그리고 그 식별자T
를 사용하고자 하는 타입(T 타입을 가진 배열, T[]
)에 넣어주면 된다.
인자로 number 타입이 전달되었을 때: number 타입으로 추론
인자로 string 타입이 전달되었을 때: string 타입으로 추론
인자로 boolean 타입이 전달되었을 때: boolean 타입으로 추론
여러 타입을 한꺼번에 인자로 전달하면: 그에 따라 타입을 유추하므로 여러 타입으로 추론
이렇게 타입스크립트는 인자로 전달된 값을 보고 타입을 유추하고, 기본적으로 유추한 타입으로 call signature를 보여준다.
이것이 바로 제네릭의 핵심이다.
인자에 제네릭 타입이 든 배열을 보내면, 리턴 값으로 void 대신, 제네릭 타입이 하나 나오게 설정해 보자.
배열의 첫 번째 요소 반환하는 함수를 만들어보자.
type SuperPrint = {
<T>(arr: T[]): T //리턴값에 제네릭 식별자 넣어주기
}
// 배열의 첫 번째 요소 반환하는 함수
const superPrint: SuperPrint = (arr) => arr[0];
const a = superPrint([1,2,3]);
const b = superPrint(["a","b","c"]);
const c = superPrint([true, false]);
const d = superPrint([1,"a", true]);
console.log(a); //1
console.log(b); //"a"
console.log(c); //true
console.log(d); //1
인자에 string 타입의 배열을 보내면, 리턴 값도 string 타입이 나온다.
<T>(arr: T[]): T
이렇게 타입스크립트에게 타입을 유추하도록 하면 된다.
그러면 타입스크립트는 그 타입의 배열이 될 것이라는 것을 인지하고 그 타입 중 하나를 리턴한다.
이렇게 superPrint()
라는 같은 함수를 사용하지만, 콜 시그니처는 타입별로 다 다르게 사용할 수 있다.
any를 넣는게 낫지 않을까 생각 할수도 있지만 그건 아니다 ^^~
제네릭은 내가 요구한 대로 시그니처를 생성해 주는 도구라고 생각하면 된다.
제네릭을 하나 더 추가하고 싶다면 이렇게 하면 된다.
//꺽쇠 안에 제네릭 추가 작성
//사용할 곳에 제네릭 넣기: 두번째 인자
type SuperPrint = {
<T, V>(a: T[], b: V): T
}
//그러면 타입스크립트는 제네릭을 처음 인식 했을 때와 제네릭의 순서를 기반으로 제네릭의 타입을 알게 된다.
const superPrint: SuperPrint = (a, b) => a[0];
//여기에서 요구에 따라 콜 시그니처가 생성된다.
const a = superPrint([1,2,3], "");
const b = superPrint(["a","b","c"], 1);
const c = superPrint([true, false], "");
const d = superPrint([1,"a", true], false);
console.log(a);
console.log(b);
console.log(c);
console.log(d);
//SuperPrint 타입 만들고, 콜 시그니처 작성
type SuperPrint = {
<T, V>(a: T[], b: V): T
}
//함수에 SuperPrint 타입 적용
const superPrint: SuperPrint = (a) => a[0]
실제로는 내가 제네릭을 사용해서 직접 콜 시그니처를 만드는 일은 거의 없을거라고 한다.
주로 제네릭을 통해 생성된 라이브러리나 패키지를 사용하게 되기 때문이다.
즉, 다른 개발자가 사용할 기능을 개발하기 위해 라이브러리를 만들 경우에는 제네릭이 유용하다.
nextJS, nestJS, reactJS를 사용한다면, 대부분 제네릭을 사용만 하고 만들지는 않을 거라고 한다.
//SuperPrint 타입 만들고, 콜 시그니처 작성
type SuperPrint = {
<T, V>(a: T[], b: V): T
}
//함수에 SuperPrint 타입 적용
const superPrint: SuperPrint = (a) => a[0]
//일반 함수
function superPrint<T>(a: T[]){
return a[0];
}
//화살표 함수
const superPrint: <T> = (a:T[]): T => {
return a[0]
}
type BTS<T> = {
name: string,
age: number,
extraInfo: T, //extraInfo 가 어떤 타입이든 될 수 있다면 제네릭을 만들고, 지정해주면 된다.
}
//BTS 타입을 가지는 rm 객체 생성
//extraInfo로 짜장면을 좋아한다고 알려줘 보자.
//제네릭에 {favoriteFood: string} 전달
const rm: BTS<{favoriteFood: string}> = {
name: "RM",
age: 28,
extraInfo: {
favoriteFood: "짜장면"
},
}
이렇게 확장해서 사용할 수 있다.
아래 처럼 쪼개도 된다.
type BTS<T> = {
name: string,
age: number,
extraInfo: T, //extraInfo 가 어떤 타입이든 될 수 있다면 제네릭을 만들고, 지정해주면 된다.
}
type BtsFavFood = BTS<{favoriteFood: string}>
const rm: BtsFavFood = {
name: "RM",
age: 28,
extraInfo: {
favoriteFood: "짜장면"
},
}
더 쪼갤 수도 있다..^^
type BTS<T> = {
name: string,
age: number,
extraInfo: T, //extraInfo 가 어떤 타입이든 될 수 있다면 제네릭을 만들고, 지정해주면 된다.
}
type RmExtra = { favoriteFood: string }
type BtsFavFood = BTS<RmExtra>
const rm: BtsFavFood = {
name: "RM",
age: 28,
extraInfo: {
favoriteFood: "짜장면"
},
}
이런식으로 원하는대로 코드를 확장하는 것이 가능하다.
타입을 생성하고 그 타입을 또 다른 타입 안에 넣어서 사용할 수 있다.
진은 extrainfo가 없다고 해보자.
그러면 제네릭에 null
을 전달하면 된다.
const jin: BTS<null> = {
name: "JIN",
age: 30,
extraInfo: null,
}
타입을 이렇게 재사용할 수 있다.
type A = Array<number> //제네릭에 number를 넣어 number로 된 Array라고 하자.
let a: A = [1, 2, 3, 4] //number로 된 어레이를 넣어보면 잘 작동한다.
//인자로 들어오는 타입: number[]
function printAllNumbers(arr: number[]){
arr.forEach( i => console.log(i));
};
//🔥 제네릭을 사용하여 인자로 들어오는 타입 설정: Array<number>
function printAllNumbers2(arr: Array<number>{
arr.forEach( i => console.log(i));
}
//useState는 제네릭을 받는다.
//타입스크립트가 useState의 타입을 알 수 있도록 이런 식으로 작성해야 한다.
const [state, setState] = useState<number>();
//이렇게 제네릭을 보내면, useState의 콜 시그니처는 number타입의 useState가 된다.