타입 조합
교차 타입
교차 타입을 사용하면 여러 가지 타입을 결합하여 하나의 단일 타입으로 만들 수 있다.
교차 타입은 &을 사용해서 표기한다. 결과물로 탄생한 교차 타입에는 타입 별칭을 붙일 수 있다.
type Item = {
id: number;
name: string;
};
type Product = Item & { price: number };
기존에 존재하는 다른 타입들을 합쳐서 해당 타입의 모든 멤버를 가지는 새로운 타입을 생성하는
것이다.
유니온 타입
교차 타입이 A와 B를 모두 만족하는 경우라면 유니온 타입은 A 또는 B 중 하나가 될 수 있는
타입을 말하며 A | B 같이 표기한다.
주로 특정 변수가 가질 수 있는 타입을 전부 나열하는 용도로 사용된다. 교차 타입과 마찬가지로
2개 이상의 타입을 이어 붙일 수 있고 타입 별칭을 통해 중복을 줄일 수도 있다.
type Product = {
id: number;
name: string;
type: string;
price: number;
};
type CardItem = {
id: number;
name: string;
number: number;
img: string;
};
type PromotionItem = Product | CardItem;
const printItem = (item: PromotionItem) => {
console.log(item.id);
console.log(item.name);
// 두 타입의 공통적인 프로퍼티가 id, name 뿐이므로 이 둘만 사용 가능
};
인덱스 시그니처
인덱스 시그니처는 특정 타입의 속성 이름은 알 수 없지만 속성값의 타입을 알고 있을 때 사용
하는 문법이다. 인터페이스 내부에 [key:K] : T 꼴로 타입을 명시해주면 되는데 이는
해당 타입의 속성 키는 모두 K 타입이어야 하고 속성값은 모두 T 타입을 가져야 한다는 의미다.
interface IndexSignatureEx {
[key: number]: string;
}
const item: IndexSignatureEx = {
1: "JS",
2: "TS",
};
인덱스 시그니처를 선언할 때 다른 속성을 추가로 명시해줄 수 있는데 이때 추가로 명시된
속성은 인덱스 시그니처에 포함되는 타입이어야 한다.
interface IndexSignatureEx {
[key: string]: number | boolean;
isValid: boolean;
name: string; // 에러 발생
}
인덱스드 엑세스 타입
인덱스드 엑세스 타입은 다른 타입의 특정 속성이 가지는 타입을 조회하기 위해 사용된다.
인덱스에 사용되는 타입 또한 그 자체로 타입이기 때문에 유니오 타입, keyof, 타입 별칭 등의
표현을 사용할 수 있다.
type Example = {
a: number;
b: string;
c: boolean;
};
type IndexedAccess = Example["a"]; // number
type IndexedAccess2 = Example["a" | "b"]; // numer | string
type IndexedAccess3 = Example[keyof Example]; // number | string | boolean
type ExAlias = "b" | "c";
type IndexedAccess4 = Example[ExAlias]; // string | boolean
위 예시의 IndexedAccess들은 Example 타입의 각 속성이 가지는 타입을 조회하기 위한
인덱스드 엑세스 타입이다.
또한 배열의 요소 타입을 조회하기 위해 사용하는 경우가 있다.
배열 타입의 모든 요소는 전부 동일한 타입을 가지며 배열의 인덱스는 숫자 타입이다.
따라서 number로 인덱싱하여 배열 요소를 얻은 다음 typeof 연산자를 붙여주면 해당
배열 요소의 타입을 가져올 수 있다.
const PromotionList = [
{ type: "product", name: "chicken" },
{ type: "product", name: "pizza" },
{ type: "card", name: "cheer-up" },
];
// 배열의 첫 번째 요소 타입을 추출하는 유틸리티 타입
type ElementOf<T extends Array<unknown>> = T[number];
// PromotionList의 첫 번째 요소 타입 추출
type PromotionItemType = ElementOf<typeof PromotionList>;
// type PromotionItemType = {type : string; name: string}
맵드 타입
맵드 타입은 다른 타입을 기반으로 한 타입을 선언할 때 사용하는 문법인데,
인덱스 시그니처 문법을 사용해서 반복적인 타입 선언을 효과적으로 줄일 수 있다.
type Example = {
a: number;
b: string;
c: boolean;
};
type Subset<T> = {
[K in keyof T]?: T[K];
};
const aExam: Subset<Example> = { a: 3 };
const bExam: Subset<Example> = { a: 1, c: true };
const cExam: Subset<Example> = { b: "hello" };
맵드 타입에서 매핑할 때는 readonly와 ?를 수식어로 적용할 수 있다. 이러한 수식어를
더해주는 것뿐만 아니라 제거할 수도 있다. 기존 타입에 존재하던 수식어 앞에 -를 붙여주면
해당 수식어를 제거한 타입을 선언할 수 있다.
type ReadOnly = {
readonly a: number;
readonly b: string;
};
type Subset<T> = {
-readonly [K in keyof T]: T[K];
};
type Result = Subset<ReadOnly>;
// type Result = { a: number; b: string; }
type Optional = {
a?: string;
b?: number;
c?: boolean;
};
type Subset2<T> = {
[K in keyof T]-?: T[K];
};
type Result2 = Subset2<Optional>;
// type Result2 = { a: string; b: number; c: boolean; }
또한 맵드 타입에서는 as 키워드를 사용하여 키를 재지정할 수 있다.
템플릿 리터럴 타입
템플릿 리터럴 타입은 자바스크립트의 템플릿 리터럴 문자열을 사용하여 문자열 리터럴 타입을
선언할 수 있는 문법이다.
type Stage = "init" | "select-image" | "edit-image";
type StageNames = `${Stage}=stage`;
// "init=stage" | "select-image=stage" | "edit-image=stage"
템플릿 리터럴을 사용하여 `${Stage}-stage`와 같이 변수 자리에 문자열 리터럴의 유니온
타입인 Stage를 넣으면 해당 유니온 타입(Stage 타입) 멤버들이 차례대로 해당 변수에 들어가
-stage가 붙은 문자열 리터럴의 유니온 탙입을 결과로 반환한다.
제네릭
제네릭은 정적 언어에서 다양한 타입 간에 재사용성을 높이기 위해 사용하는 문법이다.
제네릭의 사전적 의미를 찾아보면 특징이 없거나 일반적인 것을 뜻한다. 타입스크립트의 제네릭도
이와 비슷한 맥락을 가지고 있는데 한마디로 일반화된 데이터 타입이라고 할 수 있다.
좀 더 자세히 풀어보면 함수, 타입, 클래스 등에서 내부적으로 사용할 타입을 미리 정해두지 않고
타입 변수를 사용해서 해당 위치를 비워 둔 다음에, 실제로 그 값을 사용할 때 외부에서 타입
변수 자리에 타입을 지정하여 사용하는 방식을 말한다.
이렇게 하면 함수, 타입, 클래스 등 여러 타입에 대해 하나하나 따로 정의하지 않아도 되기 때문에
재사용성이 크게 향상된다. 보통 타입 변수명으로 T(Type), E(Element), K(Key), V(Value) 등
한 글자로 된 이름을 많이 사용한다.
type ArrayType<T> = T[];
const array1: ArrayType<string> = ["치킨", "우동", "피자"];
제네릭은 any와 명확히 다르다. 둘의 차이를 배열을 통해 알아보자면
any 타입의 배열에서는 배열 요소들의 타입이 전부 같지 않을 수 있다. 쉽게 말해 타입 정보를
잃어버린다고 볼 수 있는데 이는 any를 사용하면 타입 검사를 하지 않고 모든 타입이 허용되는
타입으로 취급되는 것이다. 반면에 제네릭은 아무 타입이나 무분별하게 받는 게 아니라 배열
생성 시점에 원하는 타입으로 특정할 수 있다. 즉, 제네릭을 사용하면 배열 요소가 전부 동일한
타입이라고 보장할 수 있다.
제네릭은 일반화된 데이터 타입을 의미하므로 함수나 클래스 등의 내부에서 제네릭을 사용할
때 어떤 타입이든 될 수 있다는 개념을 알고 있어야 한다. 예를 들어 배열에만 존재하는 length
속성을 제네릭에서 참조하려고 하면 당연히 에러가 발생한다. 이럴 때는 제네릭 꺽쇠괄호
내부에 length 속성을 가진 타입만 받는다 라는 제약을 걸어줄 수 있다.
interface TypeWithLength {
length: number;
}
function example<T extends TypeWithLength>(arg: T): number {
return arg.length;
}
제네릭 사용 시 파일 확장자가 tsx일 때 화살표 함수에 제네릭을 사용하면 에러가 발생한다.
이러한 상황을 피하기 위해서는 제네릭 부분에 extends 키워드를 사용하여 컴파일러에게
특정 타입의 하위 타입만 올 수 있음을 확실히 알려주면 된다. 보통 제네릭을 사용할 때는
function 키워드로 선언하는 경우가 많다.
제네릭 사용법
함수의 제네릭
어떤 함수의 매개변수나 반환 값에 다양한 타입을 넣고 싶을 때 제네릭을 사용할 수 있다.
function ReadOnlyRepository<T>(
target: ObjectType<T> | EntitySchema<T> | string
): Repository<T> {
return getConnection("ro").getRepository(target);
}
호출 시그니처의 제네릭
호출 시그니처를 사용함으로써 개발자는 함수 호출 시 필요한 타입을 별도로 지정할 수 있는데
호출 시그니처를 사용할 때 제네릭 타입을 어디에 위치시키는지에 따라 타입의 범위와 제네릭
타입을 언제 구체 타입으로 한정할지를 결정할 수 있다.
type Example<T> = (input: T) => T;
const numberFunc: Example<number> = (input) => input * 2;
const stringFucn: Example<string> = (input) => input.toUpperCase();
numberFunc(2);
stringFucn("hello");
위 예제의 두 함수는 Example을 사용할 때 타입을 명시함으로써 제네릭 타입을 구체 타입으로
한정했다.
type TransformerWithGeneric = <T>(input: T) => T;
const generalTransformer: TransformerWithGeneric = (input) => {
return input;
};
generalTransformer<number>(42);
generalTransformer<string>("world");
위 예시는 제네릭을 호출 시그니처 앞에 선언했기 때문에 타입스크립트는
TransformerWithGeneric 타입의 함수를 실제 호출할 때 제네릭 타입을 구체 타입으로 한정한다.
제네릭 클래스
제네릭 클래스는 외부에서 입력된 타입을 클래스 내부에 적용할 수 있는 클래스이다.
class Pair<T, U> {
private first: T;
private second: U;
constructor(first: T, second: U) {
this.first = first;
this.second = second;
}
getFirst(): T {
return this.first;
}
getSecond(): U {
return this.second;
}
setFirst(value: T): void {
this.first = value;
}
setSecond(value: U): void {
this.second = value;
}
}
// Pair 클래스의 인스턴스 생성
const numberAndStringPair = new Pair<number, string>(10, 'Hello');
// 값 확인
console.log(numberAndStringPair.getFirst()); // 출력: 10
console.log(numberAndStringPair.getSecond()); // 출력: Hello
// 새로운 값 설정
numberAndStringPair.setFirst(20);
numberAndStringPair.setSecond('World');
// 변경된 값 확인
console.log(numberAndStringPair.getFirst()); // 출력: 20
console.log(numberAndStringPair.getSecond()); // 출력: World
Pair 클래스의 T와 U는 클래스의 내부에서 사용되는 타입이다.
이러한 제네릭 타입 매개변수들은클래스가 사용될 때 실제 타입으로 대체된다.
제한된 제네릭
제한된 제네릭은 타입 매개변수에 대한 제약 조건을 설정하는 기능을 말한다.
A라는 타입을 B라는 타입으로 제약을 하기 위해서는 A 타입의 매개변수는 extends 키워드를
사용하여 B타입을 상속해야 한다.
type Student = {
name: string;
age: number;
};
function printStudent<T extends Student>(obj: T, key: keyof T): void {
console.log(obj[key]);
}
예제의 printStudent의 타입 매개변수 T는 Student라는 타입으로 제약 조건이 설정되어 있다.
이처럼 타입 매개변수가 특정 타입에 묶여 있을 때 해당 키를 바운드 타입 매개변수라 한다.
또한 상속된 Student는 T의 상한 한계라고 부른다.
타입스크립트는 구조적 타이핑의 특성을 지니고 있어 제한된 타입과 유사한 타입의 값을 넘겨
받아도 에러가 발생하지 않는다.
// 제한된 제네릭으로 상속받은 Student 타입과 유사한 타입의 값을 넘겨받을 경우 컴파일 에러가 발생하지 않음
printStudent({ name: 'jay', age: 25 }, 'name'); // jay
printStudent({ name: 'mark', age: 20, class: 'A' }, 'name'); // mark
printStudent({ name: 'jhon', class: 'A', age: 27 }, 'name'); // jhon
// 유사하지 않은 타입의 값을 넘겨받을 경우 컴파일 에러 발생
printStudent({name:"jay",class:"A"},"name") // 에러 발생
확장된 제네릭
제네릭 타입은 여러 타입을 상속받을 수 있으며 타입 매개변수를 여러개 둘 수 있다.
하지만 extends로 타입을 제약하면 제네릭의 유연성을 잃을 수 있는데, 제네릭의 유연성을
잃지 않으면서 타입을 제약해야 할 때는 타입 매개변수에 유니온 타입을 상속해서
<T extends string | number> 와 같이 선언하면 된다.
유니온 타입으로 T가 여러 타입을 받게 할 수 있지만 타입 매개변수가 여러 개일 때는
처리할 수 없으므로 이럴때는 매개변수를 추가하여 선언한다.
// T는 string 또는 number 타입, U는 string 타입
function printType<T extends string | number, U extends string>(
a: T,
b: U
): void {
// T가 string인 경우
if (typeof a === 'string') {
console.log(`Type of 'a': string, Value of 'a': ${a.toUpperCase()}`);
}
// T가 number인 경우
else if (typeof a === 'number') {
console.log(`Type of 'a': number, Value of 'a': ${a.toFixed(2)}`);
}
console.log(`Type of 'b': ${typeof b}`);
}
제네릭 예시
제네릭의 장점은 다양한 타입을 받게 함으로써 코드를 효율적으로 재사용할 수 있는 것이다.
그 중 API 응답 값의 타입을 지정할 때 현업에서 가장 많이 활용된다. 예시는 다음과 같다.
export interface MobileApiResponse<Data>{
data:Data;
statusCode:string;
statusMessage?:string;
}
MobileApiResponse의 data라는 속성은 API의 응답 값에 따라서 달라진다. 그러므로
제네릭을 사용하여 타입매개변수인 Data로 지정했다.
// 어딘가에 선언된 타입 Price를 넘겨줌
const fetchPriceInfo = (): Promise<MobileApiResponse<Price>> => {
const priceUrl = 'price URL';
return request({
method: 'GET',
url: priceUrl,
});
};
// 어딘가에 선언된 타입 Order를 넘겨줌
const fetchOrderInfo = (): Promise<MobileApiResponse<Order>> => {
const orderUrl = 'order URL';
return request({
method: 'GET',
url: orderUrl,
});
};
이처럼 다양한 API 응답 값의 타입으로 MobileApiResponse를 재사용할 수 있다.
이런식으로 제네릭을 활용하며 가독성을 높이고 효율적인 코드 작성이 가능하지만 굳이
필요하지 않은 곳에서 사용하면 오히려 코드를 복잡하게 할 수 있다.
제네릭을 사용하지 않아도 되는 경우
1. 제네릭을 굳이 사용하지 않아도 되는 타입
type Gtype<T> = T;
type RequirementType = "USE"|"UN_USE"|"NON_SELECT";
interface Order {
gerRequirement():Gtype<RequirementType>
};
type RequirementType = "USE"|"UN_USE"|"NON_SELECT";
interface Order {
gerRequirement():RequirementType;
};
예제의 두 코드는 동일한 기능을 수행한다.
이처럼 제네릭이 필요하지 않을 때 사용하면 코드 길이만 늘어나고 가독성을 해칠 수 있다.
2. any 사용하기
any 타입은 모든 타입을 허용하기 때문에 사실상 자바스크립트와 동일한 방식으로 코드를
작성하는 것과 같다. 따라서 any를 사용하면 제네릭 포함 타입을 지정하는 의미가 사라진다.
type ReturnType<T = any> {
//...
}
3. 가독성을 고려하지 않은 사용
과도한 제네릭은 가독성을 해쳐 코드의 해석을 어렵게 한다.
복잡한 제네릭은 의미 단위로 분할해서 사용하는 것이 좋다.
ReturnType<Record<OrderType,Partial<Record<CommonOrderStatus | CommonReturnStatus,Partial<Record<OrderRoleType,string[]>>>>>>
type CommonStatus = CommonOrderStatus | CommonReturnStatus;
type PartialOrderRole = Partial<Record<OrderRoleType,string[]>>;
type RecordCommonOrder = Record<CommonStatus, PartialOrderRole>;
type RecordOrder = Record<OrderType,Partial<RecordCommonOrder>>;
ReturnType<RecordOrder>
'TypeScript' 카테고리의 다른 글
우아한 타입스크립트 with 리액트 / 6장 타입스크립트 컴파일 (2) | 2024.12.12 |
---|---|
우아한 타입 스크립트 with 리액트 / 4장 타입 확장하기, 좁히기 (2) | 2024.11.29 |
우아한 타입스크립트 with 리액트 / 3장 고급타입 (1) (3) | 2024.11.18 |