타입스크립트 핸드북과 Udemy강의를 토대로 Typescript를 공부합니다.
배열 타입과 프로미스 타입의 정보를 알고싶을 때 제네릭 타입을 사용한다.
배열은 서로 다른 데이터를 지니는 요소로 구성된다.
문자열을 요소로 갖는 배열에서 요소를 모두 지우면,
배열은 any타입으로 추론하므로 어떤 데이터든 입력할 수 있다.
const names = ['Max', 'Manuel'];
//string[] (문자열을 요소로 갖는 배열 타입)
const names = [];
//Any[] (어떤 데이터든 요소로 갖는 배열 타입)
//제네릭 타입 작성하기
const names: Array = [];
//에러! 제네릭 타입이며 하나의 타입 인수가 필요하다고 한다.
//'Array<T>'<-이와 같은 표기가 제네릭 타입!
//(배열 타입은 어떤 타입의 데이터가 저장되든 상관하지 않지만, 적어도 정보가 저장되는 것인지에 대해서는 확인을 한다.(아무것도 입력하지 않더라도))
//배열 타입을 정의하는 방법 1 - 일반적
const names: any[] = [];
//any 배열 타입으로 설정하는 것이 아무것도 지정하지 않는 것보다 낫다.
//배열 타입을 정의하는 방법 2 - 제네릭 타입
const names: Array<string> = [];
const names: Array<string | number> = [];
//유니언 타입도 가능
//제네릭 타입은 타입스크립트에 내장된 타입이며 다른 타입과 연결된다.
//제네릭 타입의 개념!
names[0].split(' ');
//ex.배열에 저장하는 데이터가 문자열임을 알고있다면
//배열 내에 요소에 접근할 때마다 문자열로 작업을 수행할 수 있고 문자열 매소드를 호출해도 에러는 발생하지 않는다.
프로미스
자바스크립트 기능, 타입스크립트 기능이 아니다.
const promise: Promise<string> = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(10);
}, 2000);
});
promise.then(data => {
data.split(' ');
})
//프로미스 생성자 함수에서 두 인수를 받는데,
//resolve, reject 함수를 받는다.
//프로미스도 결국 반환하는 것이 있고, 그 반환하는 것에 타입을 설정할 수 있다.
//promise: Promise<string>
//반환할 것을 알면 반환할 것에 관한 메소드를 쓸 수 있다.
//다른 타입 종류의 메소드를 쓰면 에러가 발생한다.
제네릭 타입을 사용하면?
타입 안정성을 확보할 수 있다.
=작업에 큰 유연성이 있고,
타입스크립트에게 배열은 어떤 데이터를 저장해야 하는지 알고, 프로미스는 어떤 데이터를 반환해야 하는지 알고 있다.
프로미스나 배열로 작업하는 경우, 제네릭 타입을 사용하면 추가적인 타입 정보를 얻는데 도움이 된다.
function merge(objA: object, objB:object){
return Object.assign(objA,objB);
}//두 객체를 병합하고 새 객체를 반환하는 함수
const mergeObj = merge({name: 'Max'}, {age: 30}) //함수에 전달인자 넣고 실행
console.log(mergeObj);
//{name: 'Max', age: 30} 두 객체를 병합하여 반환됨
console.log(mergeObj.name);
//에러! name 키에 접근할 수 없다.
//해결방법 1 - 병합 후의 객체의 타입을 미리 설정
const mergeObj = merge({name: 'Max'}, {age: 30} as {name: string, age: number});
//번거롭다. -> 제네릭 타입으로 해결!
//해결방법 2 - 제네릭 타입 사용
//식별자 T,U(알파벳 순서)
//type의 첫글자인 T를 입력하는 게 일반적,
//한글자가 아니어도 되지만, 관례상 한글자 사용
//식별자를 각 매개변수의 타입으로 설정
function merge<T, U>(objA: T, objB:U){
return Object.assign(objA,objB);
}
//반환값 타입 =T&U : 이 함수가 T와 U의 인터섹션(&,교차점)을 반환한다고 추론한다.
//Object.assign이 인터섹션을 반환하여 Object.assign의 결과가 반환되므로 타입스크립트는 자동적으로 이 함수가 인터섹션을 반환한다고 인식한다.
const mergeObj = merge({name: 'Max'}, {age: 30})//다시 전달인자넣고 함수 실행
//const mergeObj: {name: string;} & {age: number;} 이라고 추론
//타입스크립트는 병합된 객체에 저장된 요소가 이 두 객체 타입의 인터섹션이라고 인식한다.
//function merge(objA: object, objB:object)
//처음의 미상의 두 객체의 인터섹션은 새로운 미상의 객체일 뿐이므로 추가적인 타입 정보가 없다.
//function merge<T, U>(objA: T, objB:U)
//제네릭 타입을 사용하면 이 두 매개변수가 종종 서로 다른 타입이 될 수 있다고 타입스크립트에 알려줄 수 있으므로,
//무작위의 객체 타입으로 작업하는 것이 아닌 다양한 타입 데이터를 얻고자 한다는 것을 타입스크립트가 인식하게 된다.
//이 함수가 해당 데이터의 인터섹션을 반환하게 되므로 mergeObj에 저장된 데이터가 두 입력값 데이터의 인터섹션임을 타입스크립트가 인식할 수 있다.
console.log(mergeObj.name);
//이제 name키 접근 가능
//타입스크립트에게 구체적으로 어떤 타입을 작성해야 하는지 명시적 타입 설정을 할 수도 있다.
const mergeObj = merge<{name: string}, {age: number}>({name: 'Max'}, {age: 30})
//하지만 이렇게 하지 않아도 T와 U를 지정하여 타입스크립트가 간단히 추론한 타입을 함수 호출에 사용할 수 있다.
정리
어떤 타입이 되든 상관 없지만,
타입스크립트는 두 매개변수에 해당하는 특정 타입들이 있고,
여기서 어떤 특정 객체를 갖는 게 아닌 두 매개변수의 인터섹션이 반환된다는 것을 인식한다.
이는 타입스크립트에게 두 가지 다른 타입을 입력했다고 알려준 것뿐 아니라 이 함수를 정의할 때 이러한 타입들이 고정적으로 설정되지 않고 함수를 호출할 때 동적일 수 있도록 설정된다.
제네릭이 실제로 작동하는 방식!
함수를 호출하면 타입스크립트는 두 인수에 대한 타입을 추론하여, T에 대해서는 객체 타입을 문자열을 지닌 name 속성이 있는 객체로 작성하고, U에 대해서는 숫자형 타입인 age 속성을 지닌 객체 타입으로 작성한다.
//위와 같은 두 객체를 병합한 객체를 반환하는 함수
function merge<T, U>(objA: T, objB:U){
return Object.assign(objA,objB);
}
const mergeObj = merge({name: 'Max'}, 30)💡//객체가 아닌 숫자만 전달인자로 넣음
console.log(mergeObj.name); //'Max'
//objB 전달인자가 객체타입이 아니더라도
//조용히 실패할 뿐 에러없이 컴파일되는게 문제!
//T와 U가 어떤 객체인지는 상관 없어도 일단은 항상 객체여야 한다는 것은 신경을 써야 한다.
T와 U의 타입에 제약을 둬서 제네릭 타입으로 지정
-> 타입 제약 조건을 사용
제네릭 타입의 경우, 제네릭 타입을 기반으로 할 수 있는 타입에 대한 특정 제약 조건을 설정할 수 있다.
function merge<T, U 💡extends object>(objA: T, objB:U){
return Object.assign(objA,objB);
}
//U 타입이 어떤 구조를 가지든 아무 객체가 되어도 상관 없지만 일단은 객체여야 한다는 의미
const mergeObj = merge({name: 'Max'}, 30)💡//똑같이 숫자를 전달인자로 넣음
console.log(mergeObj.name);
//이제는 올바른 에러발생! (에러내용 : 전달인자 30을 객체 타입에 할당할 수 없다)
어떤 제약 조건이든 설정 가능하고, 모든 제네릭 타입에 제약 조건을 설정할 필요는 없고, 유연하게 설정 가능하다.
제약 조건이 필요한 이유?
불필요한 에러나 이상한 작동을 방지하여 최적의 방식으로 제네릭 타입을 사용하기 위함이다.
💡interface Lengthy {
length: number;
}//인터페이스로 length속성이 number타입이라는 것을 명시
function countAndDescribe<T 💡extends Lengthy>(element: T){
let descriptionText = 'Got no value';
if(💡element.length>0) descriptionText = 'Got' + element.length + 'elements.';
return [elements, descriptionText];
}
//제약조건이 없을 때,
//타입스크립트는 element가 지닌 length가 불명확하다고 문제를 나타내므로, length가 뭔지 명시적으로 설정해야 한다.
//Lengthy로 제약조건을 설정하면,
//얻는 것이 무엇이든 length 속성도 반환되며 배열이나 문자열은 length 속성을 지닌다는 것을 알 수 있다.
console.log(countAndDescribe('Hi, there!'));
//Hi, there!, Got 9 elements.
//필요한 length 속성을 추가하면 에러없이 안전하게 문자열 반환할 수 있다.
//타입스크립트는 문자열이나 T 타입 요소를 지니는 배열이 반환된다고 추론한다.
//튜플타입을 원할 때(명시적 타입 설정)
function countAndDescribe<T extends Lengthy>(element: T)[T, string]{...}
//정확히 두 요소를 지니는 배열이 되도록 설정
//첫 번째 요소는 T 타입, 제네릭 타입의 어떤 요소든 될 수 있다.
//두 번째 요소는 문자열 타입으로 지정
console.log(countAndDescribe(['sports', 'hobby'])); //length 속성을 가지는 T타입과 문자열은 에러없음
console.log(countAndDescribe(10)); //숫자는 length 속성을 지니지 않으므로 에러발생
코드의 length 속성에 의존하여 작업을 수행하는 것이므로 length 속성은 반드시 필요하다.
보다 유연한 작업이 요구될 때 제네릭 유형을 사용하면, 제약 조건 덕분에 정확한 타입에 대해 신경 쓰지 않을 수 있다.(length 속성이 있는지만 신경쓴다면)
function extractAndConvert<T 💡extends object, U 💡extends keyof T>(obj: T, key: U){
return 'Value: ' + obj[key];
}
//첫번째 인수 객체, 두번째 인수 키, 객체의 키값을 반환하는 함수
//제약조건을 걸지 않으면 입력한 객체가 무엇이든 이 키를 가지는지 알 수 없으므로 에러 발생
//제네릭 타입을 사용하면, 타입스크립트에게 첫 번째 매개변수가 모든 유형의 객체여야 하고
//두 번째 매개변수는 해당 객체의 모든 유형의 키여야 한다고 입력된다.
//해당 객체에 키가 존재하는가?에 대해 정확한 결과를 준다.
extractAndConvert({}, 'name');
//해당 객체에 존재하지 않는 속성에 접근하려 하면 에러 발생
extractAndConvert({name: 'Max'}, 'name');
//해당 객체에 해당 키가 있으므로 에러 X
keyof 키워드를 지니는 제네릭 타입을 사용하여,
정확한 구조를 갖고자 한다는 것을 타입스크립트에게 알려줄 수 있다.
존재하지 않는 속성에 접근하려 하는 실수를 방지할 수 있다.
제네릭 함수 뿐 아니라 제네릭 클래스도 사용할 수 있다.
제네릭 클래스 사용코드
class DataStorage<T>{ //식별자 T 추가
private data: T[] = []; //T타입의 배열을 입력하여 제네릭 타입의 데이터가 저장
addItem(item: T){//메서드의 매개변수 타입도 T타입으로 지정
this.data.push(item);
}
removeItem(item: T){
this.data.splice(this.data.indexOd(item), 1);
}
getItems(){
return [...this.data]; //T[] => 제네릭 타입의 배열을 반환하는 것으로 추론
}
}
//DataStorage 종류, item의 타입을 입력하지 않으면 에러 발생
//데이터의 타입이 무엇이든 상관없고, 균일한 데이터가 되도록 하여 문자열이나 숫자나 객체가 되도록 하기 위해 제네릭 클래스를 쓰면 된다.
const textStorage = new DataStorage<string>();//새로운 객체 생성시 제네릭타입을 string으로 지정
textStorage.addITem(10); //메서드를 쓸 때 문자열만 저장하는 DataStorage이므로 숫자를 전달인자로 넣으면 에러 발생!
textStorage.addITem('Max'); //문자열을 전달인자로 넣으면 에러 X
왜 제네릭 클래스를 만들까?
텍스트를 저장하지 않아야 할 수도 있고, 다른 DataStorage에 숫자를 입력해야 할 수도 있으므로, 그렇다면 그런 DataStorage를 구현하면 된다.
DataStorage 클래스에 문자열이나 숫자형, 또는 원하는 타입을 제네릭 타입으로 지정할 수 있으므로 유연하고 완벽한 타입 지원을 구현할 수 있다.
//두번째 예시
//DataStorage클래스에 기반한 새로운 객체를 생성하고 제네릭타입은 객체로 설정
const objStorage = new DataStorage <object>();
objStorage.addItem({name: 'Max'}); //data = [{name: 'Max'}]
objStorage.addItem({name: 'Manu'});//data = [{name: 'Max'}, {name: 'Manu'}]
objStorage.removeItem({name: 'Max'});;//data = [{name: 'Manu'}]
console.log(objStorage.getItems());
//원하는 결과 : [{name: 'Manu'}] / 실제 결과 : [{name: 'Max'}]
//왜 의도하지 않은 결과가 나올까?
//위의 DataStorage클래스의 addItem과 removeItem메소드에서 사용하는 메소드는 배열에서 사용하는 메서드이다.
//객체타입의 새로운 DataStorage를 만든 경우,
//배열의 요소로서 객체가 추가되는 addItem 메서드는 잘 작동하지만
//removeItem에서 사용하는 indexOf메서드는 배열 전용 메서드인데,
//[{name: 'Max'}, {name: 'Max'}] 이 둘은 서로 다른 주소값을 가진 다른 객체이다.
//그래서 indexOf메서드를 사용해도 같은 주소값을 가진 객체가 나올리 없으므로 -1을 반환하고
//결국 -1에 해당하는 배열의 마지막 요소만 삭제 되는 것이다.
//해결책 1
//if문으로 item을 찾았는지 확인하는 것!
//indexOf의 코드가 -1과 같다면 반환되도록 해서 잘못된 item을 제거하지 않도록 한다.
removeItem(item: T){
if(this.data.indexOf(item)===-1) return;
this.data.splice(this.data.indexOd(item), 1);
}
//하지만 역시 아직 indexOf가 제대로 작동하지 못해서 계속 -1로 결과가 나오므로
//근본적으로는 indexOf가 제대로 작동하게 해야 한다.
//해결책 2
//애초에 add/removeItem 메서드에 동일한 전달인자를 전달하면,
//이 둘은 정확히 같은 객체로 indexOf가 제대로 작동할 수 있다.
const maxObj = {name: 'Max'}; //전달인자로 쓸 객체를 변수에 담고
objStorage.addItem(maxObj);
objStorage.removeItem(maxObj);
//그 변수를 메서드들의 전달인자에 넣어 같은 주소값을 가지게 하면 indexOf메서드도 잘 작동한다.
//하지만 이렇게 하는 것보다 애초에 배열의 요소에 객체 외에 원시값만 오도록 하는 것이 더 효과적이다.
class DataStorage<T extends string | number | boolean>{...}
제네릭 타입은 작업을 보다 쉽게 수행할 수 있게 해주며 완벽한 유연성의 조화를 제공합니다.
원하는 어떤 원시 값이든 사용할 수 있으며 타입 안전성을 확보할 수 있습니다.
각 DataStorage에 무엇이 저장되어 있는지 완벽히 알고 있기 때문입니다.
제네릭 타입을 사용하는 내장 타입, 특정 유틸리티 기능을 제공하는 제네릭 타입에 대한 설명
타입스크립트에만 존재, 컴파일 단계에서 이 타입들이 추가적인 타입 안전성과 유연성을 제공
무언가에 제약 조건을 설정해야 하거나 제약 조건을 부분적으로 해제할 때 사용할 수 있다.
내장된 파셜 타입
interface CourseGoal {
title: string;
description: string;
completeUntil: Date;
}
//이 함수가 항상 CourseGoal을 반환한다고 정의하면 함수가 정확하게 설정된다.
function createCourseGoal(title: string, description: string, data:Date): CourseGoal{
return {title: title, description: description, completeUntil: data};
}
//만약 위의 함수처럼 만들지 않고 다른 방법으로 만든다면...
function createCourseGoal(title: string, description: string, data:Date): CourseGoal {
let CourseGoal = {};
CourseGoal.title = title;
CourseGoal.description = description;
CourseGoal.completeUntil= data;
} //하나씩 객체에 키값 추가하는 형식 -> 에러 발생!
//자바스크립트에서는 문제없이 작동하지만 타입스크립트는 하나씩 추가하는 것을 좋아하지 않음
//-> CourseGoal 타입이 되어야 한다고 입력
let CourseGoal: CourseGoal = {};
//빈객체에 대해 에러 발생! CourseGoal타입과 맞지 않기 때문 -> 이때 파셜타입 사용!
let CourseGoal: Partial<CourseGoal> = {};
//courseGoal이 파셜 타입이어야 한다고 설정하면 제네릭 타입 덕분에 결과적으로 courseGoal을 지니게 된다.
//파셜 타입은 타입스크립트에게 중괄호 쌍{}이 courseGoal이 되는 객체임을 알려준다.
//하지만 파셜은 우리가 만든 타입 전체를 모든 속성이 선택적인 타입으로 바꾼다.
//->타입스크립트가 제공하는 내장된 타입들 중 하나인 파셜이 수행하는 기능!
//파셜 타입은 이를 모든 속성이 선택적인 객체 타입으로 바꾼다.
CourseGoal.title = title;
CourseGoal.description = description;
CourseGoal.completeUntil= data;
return courseGoal;
//에러! 문제는 반환할 수 없다. 이는 CourseGoal 파셜 타입이지 일반 CourseGoal 타입이 아니기 때문이다.
return courseGoal as CourseGoal;
//해결책 : courseGoal을 CourseGoal로 형 변환하여 해결가능!
내장 유틸리티 타입 - Readonly
const names = ['Max', 'Anna'];
names.push('Manu');
//names의 배열을 잠그고 싶다면? 더이상 요소를 추가하거나 삭제하는 일이 없게 만들고 싶다면?
const names: Readonly<string[]> = ['Max', 'Anna'];
names.push('Manu');//readonly배열에 접근하므로 에러!
//객체에서도 readonly를 사용하면 속성을 변경하거나 추가할 수 없게 만들 수 있다.
제네릭과 유니언 타입은 혼동할 수 있지만 전혀 다른 결과값을 반환한다.
class DataStorage{
private data: string[]|number[]|boolean[] = [];
addItem(item: string|number|boolean){
this.data.push(item);
}
removeItem(item: string|number|boolean){
this.data.splice(this.data.indexOd(item), 1);
}
getItems(){
return [...this.data];
}
}
//유니언 타입은 여러 타입들이 혼합된 타입으로, 어떤 타입이 오게 될지 모를 때 여러 타입을 모두 허용한다는 타입이다.
private data: string[]|number[]|boolean[] = [];
//data배열은 문자열 배열이 될수도, number배열이 될수도 boolean배열이 될수도 있지만,
//일단 하나로 정해지면 요소타입을 유니언 타입으로 지정해도 배열타입의 요소들만 data배열에 담길 수 있다.
//요소타입이 자유롭지 못하면 결국 한가지 타입을 결정해서 쓰는 것과 같다.
//메소드를 호출할 때마다 이 타입들 중 하나를 자유롭게 사용할 수 있도록 구현해야 한다.
class DataStorage<T extends string|number|boolean>{
private data: T[] = [];
...
}
//제네릭 타입을 다시 사용하면 새로운 객체를 생성할 때, 내가 원하는 데이터 타입을 선택하고 해당 타입의 데이터만 추가할 수 있다.
const stringStorage = new DataStorage<string>();
특정 타입을 고정하거나, 전체 클래스 인스턴스에 걸쳐 같은 함수를 사용하거나, 전체 함수에 걸쳐 같은 타입을 사용하고자 할 때 제네릭 타입이 유용하다.
유니언 타입은 모든 메소드 호출이나 모든 함수 호출마다 다른 타입을 지정하고자 하는 경우에 유용하다.
(제네릭은 한 타입으로 고정시킨다.)
제네릭 타입은 타입 안전성과 결합된 유연성을 제공합니다.
전달하는 값이나 클래스에서 사용하는 값을 유연하게 지정할 수 있다.
추가한 제약 조건이 있다면 그 조건만 준수하면 된다.
클래스나 제네릭 함수의 결과를 사용하여 수행하는 작업에서 모든 타입을 사용할 수 있다.
타입스크립트는 우리가 전달하는 구체적인 타입이 호출되어야 한다는 것을 인식하기 때문이다.
우리는 클래스를 인스턴스화하면서 구체적인 타입을 설정하지만 구체적인 타입을 생성하면서 클래스나 함수를 해당 타입으로 제한하기보다 유연하게 어느 정도의 제약 조건만 지정할 수 있다.
제약 조건은 선택적이며 제네릭 클래스나 제네릭 함수를 제약 조건이 없도록 지정하거나 많은 제약 조건을 지정할 수도 있다.