Part3_AdvancedType
이효경
책 읽기

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> = {
    // ...
};
  • 제네릭이 과하게 사용되면 가독성을 해치기 때문에 복잡한 제네릭은 의미 단위로 분할해서 사용하는게 좋다.