npm init -y
npm i typescript -D
npx tsc --init
function helloString(message: string): string {
return message;
}
function helloNumber(message: number): number {
return message;
}
// 더 많은 반복된 함수들...
// 그래서 보통 이런 문제를 해결하기 위해서
// 모든 타입을 받을 수 있게 설정하고,
// 모든 타입을 리턴할 수 있게 설정할 수 있습니다.
// 모든타입에 쓰이는 것이 사실 any 입니다.
// 하지만 우리가 의도한 것과 다르게 동작합니다.
function hello(message: any): any {
return message;
}
// typescript는 any로 추론하여 any에 length를 사용하는 것처럼 되어버립니다.
console.log(hello('Mark').length);
// 마찬가지로 number타입에는 length를 사용할 수 없음에도 any 타입이기에 에러가 나지 않습니다.
console.log(hello(39).length);
// 컴파일 타임에는 문제가 있다고 나오지 않지만, 런타임 때에는 문제가 발생합니다.
이제 Generics를 사용해보겠습니다.
// 인수로 들어오는 타입에 맞춰서 T 라는 이름으로 타입이 지정됩니다.
// 해당 함수 안에서 T 라는 이름으로 지정된 타입을 사용할 수 있습니다.
function helloGeneric<T>(message: T): T {
return message;
}
// 문자열을 리터럴로 넣었기 때문에 리터럴 타입으로 추론이되서,
// T 가 문자열이 아니고 Mark라고하는 값의 리터럴 타입으로 지정되었습니다.
console.log(helloGeneric('Mark'));
"Mark" 리터럴 타입으로 지정되었습니다.
console.log(helloGeneric('Mark').length);
이전과는 다르게 any가 아닌 string 타입으로 정상적으로 추론되었습니다.
"Mark"가 string 안에 포함되어있기 때문에 string의 length를 이용해서 타입이 추론됩니다.
숫자를 넣고 아까처럼 length를 사용하려 한다면..
에러가 발생하고, 자동완성에도 .length 는 나오지 않습니다.
이번에는 true
를 넣어보겠습니다.
console.log(helloGeneric(true)); // true라고하는 리터럴 타입으로 추론됩니다.
true
라고하는 리터럴 타입으로 추론됩니다.
위 함수에서 지정한 T
라는 이름을 변수처럼 함수안에서 사용할 수 있습니다.
T
는 제네릭을 선언할 때 관용적으로 사용되는 식별자로 타입 파라미터(Type parameter)
라 합니다.
T
는 Type의 약자로 다른 언어에서도 제네릭을 선언할 때 관용적으로 많이 사용된다. 이 부분에는 식별자로 사용할 수 있는 것이라면 무엇이든 들어갈 수 있다. 이를테면 $
나 _
도 가능하다는 의미다. 하지만 대개의 경우 T를 사용한다. 여기에서 T를 타입 변수(Type variables)
라고 한다.
function helloBasic<T, U, K> {
}
타입 파라미터는 여러 개를 사용할 수 있습니다.
function helloBasic<T>(message: T): T {
return message;
}
// 사용하는 방법은 2가지입니다.
// 1. 사용할 때에도 타입 파라미터를 지정해줍니다.
helloBasic<string>(39); // 에러
// T 에 string을 지정했기 때문에 위에서 매개변수를 T 타입으로 받고 있으므로 매개변수도 string 타입으로 제한을 합니다.
// 2. 제네릭기호 없이 직접 호출합니다.
// 인수로 들어간 값에 의해서 T가 추론됩니다.
// number 타입이 추론되어야 하는 데 36이라는 값을 가진 숫자리터럴 타입으로 추론됩니다.
// 우리 기준에서는 T 가 number가 되어야 하는데, typescript 입장에서는 가장 좁은 범위의 type을 추론하기 때문에
// 36을 넣게 되면 number가 아닌 36 타입으로 추론됩니다.
helloBasic(36);
사용하는 방법은 2가지입니다.
function helloBasic<T, U>(message: T, comment: U ): T {
return message;
}
helloBasic<string, number>(39); // 에러: 제네릭을 사용하여 호출했으므로 제한이 걸려서 string 타입의 첫번째 맴개변수에 값이 없다고 에러가 발생합니다.
helloBasic<string, number>("Mark", 39); // 정상실행
helloBasic(36, 39); // T는 36, U는 39 타입으로 추론됩니다.
여러개의 타입을 사용하는 경우의 실행방법.
Generic
을 이용하여 함수 안에서 Array
나 Tuple
을 표현하는 방법에 대해서 배워보겠습니다.
function helloArray<T>(message: T[]): T {
return message[0];
}
// 두 요소의 공통 타입인 string으로 추론됩니다.
helloArray(["Hello", "world"]); // 리턴 : "Hello"
두 요소의 공통 타입인 string으로 추론됩니다.
// T 가 string | number 인 유니온 타입이 됩니다.
helloArray(["Hello", 5]); // 리턴 : "Hello"
string과 숫자의 조합으로 되어 있어서 T 가 string | number 인 유니온 타입이 됩니다.
string과 number의 공통으로 사용할 수 있는 함수만 자동완성 메뉴에 나타납니다.
// message가 튜플(Tuple)형태로 처리됩니다.
// index 0 번째가 리턴 되므로 무조건 T 타입이 리턴됩니다. 그래서 리턴타입으로 T 만을 지정할 수 있습니다.
function helloTuple<T, K>(message: [T, K]):T {
return message[0];
}
helloTuple(["Hello", "world"]);
helloTuple(["Hello", 5]);
튜플형태의 사용.
지금까지 우리는 Generics
을 함수를 직접 구현하면서 사용했지만,
이번에는 함수의 타입만 선언하는 방식에 대해서 배워보도록 하겠습니다.
// 타입 알리아스로 만들어 보겠습니다.
type HelloFunctionGeneric1 = <T>(message: T) => T;
// 함수를 구현합니다.
const HelloFunction1: HelloFunctionGeneric1 = <T>(message: T): T => {
return message;
};
// 인터페이스로 함수를 표현하겠습니다.
interface HelloFunctionGeneric2 {
<T>(message: T): T;
}
// 함수를 구현합니다.
const helloFunction2: HelloFunctionGeneric2 = <T>(message: T): T => {
return message;
};
기존의 함수 앞에 <T>
만 표현해주면 됩니다.
class에 Generic을 선언하고 사용하는 방법에 대해서 배워보겠습니다.
class Person<T> {
private _name: T;
constructor(name: T) {
this._name = name;
}
}
// 이 클래스 안에서 T를 변수처럼 사용할 수 있습니다.
new Person("Mark");
// 제네릭을 먼저 string으로 세팅하면
// constructor에는 반드시 string이 들어가야하는 제한이 생깁니다.
// 이런 동작은 이전에 배운 함수때와 동일합니다.
// new Person<string>(39); // 에러
new Person<string>("Mark"); // 정상동작
이번에는 제네릭을 2개 지정해보겠습니다.
class Person<T, K> {
private _name: T;
private _age: K;
constructor(name: T, age: K) {
this._name = name;
this._age = age;
}
}
new Person("Mark", 39);
new Person<string, number>("Mark", 39);
// new Person<string, number>("Mark", "age"); // 에러
이런식으로 컴파일 타임에 에러를 미리 체크할 수 있는 역할을 하기 때문에 굉장히 유용합니다.
Generic
하고 extends
키워드를 함께 사용해보겠습니다.
extends
은 기존에 class에서 배웠던대로 상속의 의미였습니다. 하지만 Generic
으로 사용할 때에는 상속이라는 의미보다는 다른 특별한 의미로 사용됩니다.
extends
를 상속이라는 느낌으로 사용을 하게되면, 뭔가 맞지 않는다는 느낌이 듭니다.
class PersonExtends<T extends string | number> {
private _name: T;
constructor(name: T) {
this._name = name;
}
}
extends 키워드를 이용해서 타입을 더 추가해 줄 수 있습니다.
일단 <T extends string | number> 여기에서 extends를 기존의 상속이라는 개념으로 생각해본다면, T는 string | number 유니온 타입을 상속 받고 있습니다. 그럼 string | number 도 되면서 동시에 T 타입도 가질 수 있다고 생각할 수도 있는데,
그렇지 않습니다.
현재 T는 string 과 number만 가능하다는 의미입니다.
class PersonExtends<T extends string | number> {
private _name: T;
constructor(name: T) {
this._name = name;
}
}
new PersonExtends('Mark'); // 가능
new PersonExtends(39); // 가능
// new PersonExtends(true); // 에러
string
과 number
타입 이외에 다른 타입은 넣을 수 없습니다.
class PersonExtends<T extends string | number>
string과 number로 제한하고 있다고 쉽게 생각할 수 있습니다.
하지만, 코드가 복잡해지고, 본인이나 다른사람들이 사용하려고 할 때, 이런 제약을 걸어주지 않으면 예상하지 못한 에러가 날 수도 있습니다.
그래서 타입은 항상 가장 작은 범위로 제한을 해주는 것이 좋습니다.
그냥 <T>
였다면, 사용자 입장에서 "그냥 <T>
이니까 아무거나 넣어도 되겠구나" 라고 생각할 수도 있습니다. 하지만 위처럼 제한을 해준다면 훨씬 더 가이드가 잘 됩니다.
keyof
키워드와 generic 을 이용해서 타입을 적절히 찾아내고 활용할 수 있는 시스템을 만들어 보겠습니다.
시스템이라고 하면 거창해 보이지만 그냥 컴파일 타임에 타입을 정확하게 찾아낼 수 있는 방식이라고 생각하면 됩니다.
interface IPerson {
name: string;
age: number;
}
const person: IPerson = {
name: 'Mark',
age: 39
}
// 에러는 나오지 않지만, 아래 함수는 잘못되었습니다.
// key가 "name" 이면 string을 넣을 수 있고,
// "age" 이면 number를 넣을 수 있게 타입처리를 해주어야합니다.
// 현재 아래 함수는 key가 어떤 값이 들어오든 전부 string | number 유니온
// 타입으로 값을 지정할 수 있는 상태입니다.
function getProp(obj: IPerson, key: 'name' | 'age'): string | number {
return obj[key];
}
// value에는 string | number 이 올수 있는 것이 아니라
// key가 "name" 이면 string을 넣을 수 있고,
// "age" 이면 number를 넣을 수 있게 타입처리를 해주어야합니다.
// 아래 함수는 잘못되었습니다.
function setProp(obj: IPerson, key: "name" | "age", value: string | number): void {
obj[key] = value; // 에러
}
현재 key: "name" | "age"
와 value: string | number
는 관계성을 가지고 있습니다.
여기서 generic
과는 별개로 keyof
라는 키워드에 대해서 배워보겠습니다.
keyof
키워드는 타입 앞에 붙여서 새로운 타입을 만들어 냅니다.
keyof
가 무엇인지 확인해 보기 위해 아래처럼 작성해봅니다.
interface IPerson {
name: string;
age: number;
}
type Keys = keyof IPerson;
const keys: Keys = "age" // age와 name만이 자동완성으로 나옵니다.
객체에 'keyof'를 붙이면 결과물이 타입으로 나옵니다.
그 타입은 그 객체 안의 키의 이름들로 된 문자열 유니온 타입을 반환합니다.
예 : ("name" | "age")
그럼, 타입이 들어갈 자리에 "name" | "age"
대신에 keyof IPerson
을 넣을 수 있습니다.
function getProp(obj: IPerson, key: keyof IPerson): IPerson[keyof IPerson] {
return obj[key];
}
function setProp(
obj: IPerson,
key: keyof IPerson,
value: string | number
): void {
obj[key] = value;
}
IPerson[keyof IPerson] 의 결과물은
=> IPerson["name" | "age"] 이것은 또
=> IPerson["name"] | IPerson["age"] 이렇게 유니온 타입으로 나옵니다.
=> string | number
여기까지는 아직 우리가 원하는 것을 얻지 못했습니다.
우리는 getProp에 name 을 넣으면 타입으로 알아서 string이 지정되기를 원합니다.
IPerson
과 keyof IPerson
을 이용해서 그 관계성을 특정한 타입으로 지정해 주어야합니다.
여기에서 generic
이 사용이 됩니다.
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
getProp(person, 'name'); // 잘못된 속성이름을 넣게 되면 에러가 나면서 가이드를 해줍니다.
K extends keyof T
를 이용하여 해당 객체의 속성이름을 가이드해주고,
T[K]
를 이용하여 반환되는 값이 해당 속성값의 타입으로 지정되게합니다.
다음은 setProp을 바꿔보겠습니다.
먼저 T를 설정하고,
function setProp(
obj: T,
key: keyof T,
value: T[keyof T]
): void {
obj[key] = value;
}
그 다음 K를 설정하고, extends를 이용해서 T와 관계를 맺어줍니다.
function setProp<T, K extends keyof T>(
obj: T,
key: K,
value: T[K]
): void {
obj[key] = value;
}
setProp(person, "name", "Anna");
이렇게 하면 이제 유니온 타입이 아니기 때문에 setProp에서 obj[key]
에 에러가 나던 것이 더 이상 에러가 나지 않습니다.
keyof
키워드와 generic
, extends
를 이용해서 T
와 K
의 관계를 규명하는 방식으로 generic
를 활용하면 타이핑이 좀 더 안전하게 되었습니다.
이렇게 작성해주면, 런타임 가기 전에 컴파일 타임에 타이핑을 이용해서 버그나 오류를 발견해 낼 수 있습니다.
지금까지 generic
에 대해서 알아봤습니다.
generic
를 이용하면 나중에 조건부 타이핑을 이용해서 좀 더 심화된 작업까지 할 수 있습니다.