3장 고급타입
3.1 타입스크립트만의 독자적 타입 시스템
3.2 타입조합
1. 교차타입
- 여러가지 타입을 결합하여 하나의 단일 타입으로 만들수가 있다.
- 기존에 존재하는 다른 타입들을 합쳐서 해당 타입의 모든 멤버를 가지는 새로운 타입을 생성하는 것이다. &을 사용하여 표기한다.
- 예시 : 아래처럼 ProductItemWithDiscount 타입의 변수를 선언하고 값을 할당하면 ProductItem의 모든 멤버와 discountAmount까지 멤버로 가지게 된다.
type ProductItem = {
id: number;
name: string;
type: string;
price: number;
imageUrl: string;
quantity: number;
};
type ProductItemWithDiscount = ProductItem & { discountAmount: number };
2. 유니온 타입
- 여러 타입 중 1개의 타입이 될 수 있는 타입이다.
|
로 표기한다.- 주로 특정변수가 가질 수 있는 타입을 전부 나열하는 용도로 사용한다.
- 예시 : ProductEventItem은 ProuctItem이 될수도 있고, CardItem이 될수도 있다.
type CardItem = {
id: number;
name: string;
type: string;
imageUrl: string;
};
type PromotionEventItem = ProductItem | CardItem;
3. 인덱스 시그니처
- 특정 타입의 속성 이름은 알 수 없지만 속성값의 타입을 알고 있을 때 사용하는 문법
[key: k]: T
꼴로 타입을 명시해주면 되는데 이는 해당 타입의 속성 키는 모두 K 타입이어야 하고 속성값은 모두 T타입을 가져야 한다는 의미다.
interface IndexSignatureEx {
[key: string]: number;
}
- 선언할 때 다른 속성을 추가로 명시해줄 수 있는데 추가로 명시된 속성은 인덱스 시그니처에 포함되는 타입이어야 한다.
interface IndexSignatureEx2 {
[key: string]: number | boolean;
length: number;
isValid: boolean;
name: string; //에러 발생
}
4. 인덱스드 엑세스 타입
- 인덱스드 엑세스 타입은 다른 타입의 특정 속성이 가지는 타입을 조회하기 위해 사용된다.
- 아래는 객체에서의 인덱스드 엑세스 타입 예시이다.
type Example = {
a: number;
b: string;
c: boolean;
};
type IndexedAccess = Example['a'];
type IndexedAccess2 = Example['a' | 'b']; // number | string
type IndexedAccess3 = Example[keyof Example]; //number | string | boolean
type ExAlias = 'b' | 'c';
type IndexedAccess4 = Example[ExAlias]; // string | boolean
- 배열의 요소 타입을 조회하기 위해 사용되기도 한다.
- 배열 타입의 모든 요소는 전부 동일한 타입을 가지며 배열의 인덱스는 숫자 타입이다. 따라서 number로 인덱싱하여 배열 요소를 얻은 다음 typeof 연산자를 붙여주면 해당 배열 요소의 타입을 가져올 수 있다.
const PromotionList = [
{ type: 'product', name: 'chicken' },
{ type: 'product', name: 'pizza' },
{ type: 'card', name: 'cheer-up' }
];
type ElementOf<T> = (typeof T)[number];
type ElementOfProduct<T> = (typeof T)[number]['product'];
type ElementOfName<T> = (typeof T)[number]['name'];
//type PromotionItemType = {type:string; name:string}
type PromotionItemType = ElementOf<PromotionList>;
//type PromotionItemProductType = string;
type PromotionItemProductType = ElementOfProduct<PromotionList>;
//type PromotionItemNameType = string;
type PromotionItemNameType = ElementOfProduct<PromotionList>;
5장 맵드 타입
- 다른 타입을 기반으로 한 타입을 선언할 때 사용하는 문법
- 인덱스 시그니처 문법을 사용해서 반복적인 타입 선언을 효과적으로 줄일 수 있다.
type Example = {
a: number;
b: string;
c: boolean;
}
type Subset<T> = {
[K in key of T]?: T[K];
}
const aExample: Subset<Example> = {a:3};
const bExample: Subset<Example> = {b:"hello"};
const aExample: Subset<Example> = {a:4, c:true};
- 맵드 타입에서 매핑할 때는 readonly와 ?를 수식어로 적용할 수 있다. 맵드 타입의 특이한 점은 이러한 수식어를 더해주는 것뿐만 아니라 제거할 수도 있다는 것이다. 기존 타입에 존재하던 readonlysk ? 앞에 -를 붙여주면 해당 수식어를 제거한 타입을 선언할 수 있다.
type ReadOnlyEx = {
readonly a: number;
readonly b: string;
};
type CreateMuteable<Type> = {
-readonly [Property in keyof Type]: Type[Property];
};
type ResultType = CreateMutable<ReadOnlyEx>;
type OptionalEx = {
a?: number;
b?: string;
c: boolean;
};
type Concreate<Type> = {
[Property in keyof Type]-?: Type[Property];
};
type ResultType = Concreate<OptionalEx>;
6. 템플릿 리터럴 타입
- 자바스크립트의 템플릿 리터럴 문자열을 사용하여 문자열 리터럴 타입을 선언할 수 있는 문법이다.
- Stage 타입의 모든 유니온 멤버 뒤에 -stage를 붙여서 새로운 유니온 타입을 만들었다.
type Stage = 'init' | 'select-image' | 'edit-image' | 'decorate-card' | 'capture-image';
type StageName = `${Stage}-stage`;
// "init-stage" | "select-image-stage" | "edit-image-stage" | "decorate-card-stage" | "capture-image-stage";
7. 제네릭
- 다양한 타입 간에 재사용성을 높이기 위해 사용하는 문법이다.
- 함수, 타입, 클래스 등에서 내부적으로 사용할 타입을 미리 정해두지 않고 타입 변수를 사용해서 해당 위치를 비워둔 다음에, 실제로 그 값을 사용할 때 외부에서 타입 변수 자리에 타입을 지정하여 사용하는 방식을 말한다.
- 타입에 대해 정의하지 않아도 되기 때문에 재사용성이 크게 향상된다.
- 타입변수는 일반적으로
<T>
와 같이 꺽쇠괄호 내부에 정의되며, 사용할 때는 함수에 매개변수를 넣는 것과 유사하게 원하는 타입을 넣어주면 된다.
type ExampleArrayType<T> = T[];
const array1: ExampleArrayType<string> = ['치킨', '피자', '우동'];
- any와의 차이점
- any: 타입 검사를 하지 않고 모든 타입이 허용되는 타입으로 취급된다.
- 제네릭: 모든 타입이 허용되지 않으며 선언 시점에서 원하는 타입을 특정할 수 있다.
- 제네릭을 호출할 때 반드시
<>
안에 타입을 명시해야 되는 것은 아니며 타입을 명시하는 부분을 생략하면 컴파일러가 인수를 보고 타입을 추론해준다.
function exampleFunc<T>(arg: T): T[] {
return new Array(3).fill(arg);
}
exampleFunc('hello'); //T는 string으로 추론된다.
- 특정 요소 타입을 알 수 없을때는 제네릭 타입에 기본값을 추가할 수 있다.
interface SubmitEvent<T = HTMLElement> extends SyntheticEvent<T> {
submitter: T;
}
- 제네릭은 특정한 타입에서만 존재하는 멤버를 참조하려고 하면 안된다.
function exampleFun2<T>(arg: T): number {
return arg.length; // 에러발생: Property 'length' does not exist on type 'T'
}
- 위 예시와 같은 경우에는 꺽쇠 내부에 'length 속성을 가진 타입만 받는다'라는 제약을 걸어줌으로써 length 속성을 사용할 수 있게끔 만들 수 있다.
interface TypeWithLenght {
length: number;
}
function exampleFunc2<T extends TypeWithLenght>(arg: T): number {
return arg.length;
}
- 제네릭을 사용할 때 주의해야 할 점이 있다. 파일 확장자가 tsx일 때 화살표 함수에 제네릭을 사용하면 에러가 발생한다. 이러한 상황을 피하기 위해서는 제네릭 부분에 extends 키워드를 사용해 컴파일러에게 특정 타입의 하위 타입만 올 수 있음을 확실히 알려주면 된다.
- 그냥 제네릭을 사용할 때는 function 키워드를 선언하는게 편할듯
//에러 발생: JSX element 'T' has no corresponding closing tag
const arrowExampleFunc = <T>(arg: T): T[] => {
return new Array(3).fill(arg);
};
//에러 발생X
const arrowExampleFunc = <T extends {}>(arg: T): T[] => {
return new Array(3).fill(arg);
};
3.3 제네릭 사용법
1. 함수의 제네릭
- 함수의 매개변수나 반환 값에 다양한 타입을 넣고 싶을 때 제네릭을 사용할 수 있다.
- 아래 예시처럼 T 자리에 넣는 타입에 따라 ReadOnlyRepository가 적절하게 사용될 수 있다.
function ReadOnlyRepository<T>(target: ObjectType<T> | EntitySchema<T> | string): Reposity<T> {
return getConnection('ro').getRepository(target);
}
2. 호출 시그니처의 제네릭
- 호출 시그니처는 타입스크립트의 함수 타입 문법으로 함수의 매개변수와 반환 타입을 미리 선언하는 것을 말한다.
- 함수 호출 시 필요한 타입을 별도로 지정할 수 있게 된다.
interface useSelectPaginationProps<T> {
categoryAtom: RecoilState<number>;
filterAtom: RecoilState<string[]>;
sortAtom: RecoilState<SortType>;
fetcherFunc: (props: CommonListRequest) => Promise<DefaultResponse<ContentListResponse<T>>>;
}
3. 제네릭 클래스
- 외부에서 입력된 타입을 클래스 내부에 적용할 수 있는 클래스이다.
- 클래스 이름 뒤에 타입 매개변수인
<T>
를 선언해준다. LocalDB 클래스는 외부에서{ key: string, value: Promise<Record<string, unknown>>; cacheTTL: number }
타입을 받아들여 클래스 내부에서 사용될 제네릭 타입으로 결정된다. - 제네릭 클래스를 사용하면 클래스 전체에 걸쳐 타입 매개변수가 적용된다. 특정 메서드만을 대상으로 제네릭을 적용하려면 해당 메서드를 제네릭 메서드로 선언하면 된다.
class LocalDB<T> {
/// 중략
async put(table: string, row: T): Promise<T> {
return new Promise<T>((resolved, rejected) => {
/* T 타입 데이터를 DB에 저장 */
});
}
async get(table: string, key: any): Promise<T> {
return new Promise<T>((resolved, rejected) => {
/* T 타입 데이터를 DB에서 가져옴 */
});
}
async getTable(table: string): Promise<T[]> {
return new Promise<T[]>((resolved, rejected) => {
/* T[] 타입 데이터를 DB에서 가져옴 */
});
}
}
export default class IndexedDB implements ICacheStore {
private _DB?: LocalDB<{
key: string;
value: Promise<Record<string, unknown>>;
cacheTTL: number;
}>;
private DB() {
if (!this._DB) {
this._DB = new LocalDB('localCache', {
ver: 6,
table: [{ name: TABLE_NAME, keyPath: 'key' }]
});
}
return this._DB;
}
}
4. 제한된 제네릭
- 타입 매개변수에 대한 제약 조건을 설정하는 기능을 말한다.
- 예시 : string 타입으로 제약하려면 타입 매개변수는 특정 타입을 상속해야 한다.
type ErrorRecord<Key extends string> = Exclude<Key, ErrorCodeType> extends never
? Partial<Record<Key, boolean>>
: never;
- 이처럼 타입 매개변수가 특정 타입으로 묶였을 때 키를 바운드 타입 매개변수라고 부른다. 그리고 string을 키의 상한 한계라고 한다.
- 상속받을 수 있는 타입으로는 기본 타입뿐만 아니라 상황에 따라 인터페이스나 클래스도 사용할 수 있고, 유니온 타입을 선언할 수도 있다.
제한된 제네릭과 유니온 타입의 차이
- 제네릭 : 클래스의 인스턴스를 생성할 때 지정한 하나의 타입만 가능하다. 작업할 때 하나의 타입을 결정하고 고수해야 한다.
class DataStorage<T extends string | number | boolean> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data.splice(this.data.indexOf(item), 1);
}
getItems() {
return [...this.data];
}
}
const storage = new DataStorage<string>(); // string 타입만
storage.addItem('data');
// storage.addItem(123); // ERROR string 타입 선언했으므로 불가능
- 유니온 : 클래스 전체에 하나의 타입이 아니라 각 변수 및 함수에 지정한 유니온 타입 중 가능하다.
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.indexOf(item), 1);
}
getItems() {
return [...this.data];
}
}
const storage = new DataStorage(); // string 타입만
storage.addItem('data');
storage.addItem(123); // 적혀진 유니온 타입 중 하나이므로 가능
5. 확장된 제네릭
- 제네릭 타입은 여러 타입을 상속받을 수 있으며 타입 매개변수를 여러 개 둘 수 있다.
- 아래와 같이 타입을 제한하면 유연성을 잃어버린다. 타입을 제약할 때는 유니온 타입으로 상속하여 선언하면 된다.
<Key extends string> // 유연성 잃어버림
<Key extends string | number>
- 유니온 타입으로 여러 타입을 받을 수 있지만, 타입 매개변수가 여러 개일 때는 처리할 수 없다. 이럴 때는 매개변수를 하나 더 추가해서 선언한다.
6. 제네릭 예시
- 제네릭이 협업에서 제일 많이 사용되는 곳은 API 응답 값의 타입을 지정할 때다.
- 아래 코드를 살펴보면 API 응답 값에 따라 달라지는 data를 제네릭 타입 Data로 선언하고 있다. 이렇게 만든 MobileApiResponse는 실제 API 응답 값의 타입을 지정할 때 사용되고 있다.
export interface MobileApiResponse<Data> {
data: Data;
statusCode: string;
statusMsg?: string;
}
export const fetchPriceInfo = (): Promise<MobileApiResponse<PriceInfo>> => {
const priceUrl = 'https: ~~~'; //url 주소
return request({
method: 'GET',
url: priceUrl
});
};
export const fetchOrderInfo = (): Promise<MobileApiResponse<Order>> => {
const orderUrl = 'https: ~~~'; //url 주소
return request({
method: 'GET',
url: orderUrl
});
};
제네릭을 굳이 사용하지 않아도 되는 타입
- 제네릭이 필요하지 않을 때도 사용하면 가독성을 해칠 수 있음
- 아래처럼 GType이 다른 곳에서는 사용되지 않고 getRequirement 함수의 반환 값 타입으로만 사용되고 있다면 굳이 제네릭을 사용하지 않는게 바람직하다.
type GType<T> = T;
type RequirementType = 'USE' | 'UN_USE' | 'NON_SELECT';
interface Order {
getRequirement(): GType<RequirementType>;
}
- 아래와 같이 any를 사용하면 제네릭을 포함해 타입을 지정하는 의미가 사라지게 됨으로 any 사용을 권장하지 않는다.
type ReturnType<T = any> = {
// ...
};
- 제네릭이 과하게 사용되면 가독성을 해치기 때문에 복잡한 제네릭은 의미 단위로 분할해서 사용하는게 좋다.