[우아한 타입스크립트 with 리액트] 3장. 고급 타입
Soshy·

1. 타입스크립트만의 독자적 타입 시스템
1.1 any 타입
자바스크립트에 존재하는 모든 값을 오류 없이 받을 수 있다. 다시 말하면, 타입을 명시하지 않은 것과 동일한 효과를 나타낸다.
any 타입을 변수에 할당하는 것은 지양해야 할 패턴이다. 타입스크립트의 컴파일러 설정을 커스텀할 수 있는 tsconfig.json 파일에서 noImplicitAny옵션을 활성화하면 타입이 명시되지 않은 변수의 암묵적인any` 타입에 대한 경고를 발생시킬 수 있다.
하지만 타입스크립트에서 any 타입을 어쩔 수 없이 사용해야 할 때가 있는데, 대표적으로 3가지 사례를 들 수 있다.
개발 단계에서 임시로 값을 저장해야 할 때
매우 복잡한 구성 요소로 이루어진 개발 과정에서 추후 값이 변경될 가능성이 있거나 아직 세부 항목에 대한 타입이 확정되지 않은 경우가 생길 수 있다. 이럴 때 해당 값을 any로 지정하면 경고 없이 개발을 계속할 수 있다.
그러나 any 타입을 지나치게 남발하면 타입 안정성을 저해할 수 있다. any는 임시로 타입을 지정할 때 주로 사용되므로, 타입에 대한 세부 스펙이 나오는 시점에 다른 타입으로 대체하는 경우가 많다.
어떤 값을 받아올 지 또는 넘겨줄지 정할 수 없을 때
API 요청 및 응답 처리, 콜백 함수 전달, 타입이 잘 정제되지 않아 파악이 힘든 외부 라이브러리 등을 사용할 때는 어떤 인자를 주고받을지 특정하기 힘들다. 이처럼 주고받을 값이 명확하지 않을 때 열린 타입(any 타입)을 선언해야 할 수 있다.
type FeedbackModalParams = {
show: boolean;
content: string;
cancelButtonText?: string;
confirmButtonText?: string;
beforeOnClose?: () => void;
action?: any;
};
FeedbackModalParams라는 이름으로 선언된 타입 중에 action이라는 속성이 any로 선언된 것을 볼 수 있다. FeedbackModalParams는 피드백을 나타내기 위해 모달 창을 그릴 때 사용되는 인자를 나타내는 타입이다. 이 중 action 속성은 모달 창을 그릴 때 실행될 함수를 의미한다. 모달 창을 화면에 그릴 때 다양한 범주의 액션에 따라 인자의 개수나 타입을 일일이 명시하기 힘들 수 있다. 이럴 때 any 타입을 사용하면 다양한 액션 함수를 전달할 수 있다.
값을 예측할 수 없을 때 암묵적으로 사용
외부 라이브러리나 웹 API의 요청에 따라 다양한 값을 반환하는 API가 존재할 수 있다. 대표적인 예로 브라우저의 Fetch API를 들 수 있다. Fetch API의 일부 메서드는 요청 이후의 응답을 특정 포맷으로 파싱하는데 이때 반환 타입이 any로 매핑되어 있는 것을 볼 수 있다.
async function load() {
const response = await fetch("https://api.com");
const data = await response.json(); // response.json()의 리턴 타입은 Promise<any>로 정의되어 있다
return data;
}
1.2 unknown 타입
unknown 타입은 any 타입과 유사하게 모든 타입의 값이 할당될 수 있다. 그러나 any를 제외한 다른 타입으로 선언된 변수에는 unknown 타입 값을 할당할 수 없다.
any | unknown |
|---|---|
- 어떤 타입이든 any 타입에 할당 가능 | - 어떤 타입이든 unknown 타입에 할당 가능 |
- any 타입은 어떤 타입으로도 할당 가능 (단 never는 제외) | - unknown 타입은 any 타입 외에 다른 타입으로 할당 불가능 |
let unknownValue: unknown;
unknownValue = 100; // any 타입과 유사하게 숫자이든
unknownValue = "hello world"; // 문자열이든
unknownValue = () => console.log("this is any type"); // 함수이든 상관없이 할당이 가능하지만
let someValue1: any = unknownValue; // (O) any 타입으로 선언된 변수를 제외한 다른 변수는 모두 할당이 불가
let someValue2: number = unknownValue; // (X)
let someValue3: string = unknownValue; // (X)
unknown 타입은 이름처럼 무엇이 할당될지 아직 모르는 상태의 타입을 말한다. 이렇게만 보면 any 타입과 비슷한데 왜 unknown 타입이 추가되었을까?
any 타입을 사용하면 어떤 값이든 허용된다. 앞서 어떤 값이 할당될지 파악하기 어려운 상황에서 any 타입을 지정하여 임시로 문제를 회피하는 예시도 살펴보았다. 그리고 나중에 any 타입을 특정 타입으로 수정해야 하는 것을 깜빡하고 누락하면 어떤 값이든 전달될 수 있기 때문에 런타임에 예상치 못한 버그가 발생할 가능성이 높아진다는 것도 설명했다.
unknown 타입은 이러한 상황을 보완하기 위해 등장한 타입이다. any 타입과 유사하지만 타입 검사를 강제하고 타입이 식별된 후에 사용할 수 있기 때문에 any 타입보다 더 안전하다. 따라서 데이터 구조를 파악하기 힘들 때 any 타입 대신 unknown 타입으로 대체해서 사용하는 방법이 권장된다.
let value: unknown = "hello";
// ❌ 이렇게 그냥 쓰면 에러
value.toUpperCase();
// ✅ 타입을 확인한 후에 쓰면 OK
if (typeof value === "string") {
value.toUpperCase();
}
1.3 void 타입
함수 타입을 지정할 때에는 함수에 전달되는 매개변수의 타입과 반환하는 타입을 지정해야 한다. 이때 매개변수를 전달하지 않는 경우에는 그냥 괄호를 비워두면 되지만 아무런 값을 반환하지 않는 경우에는 어떤 타입을 매핑해야 할까? 예를 들어 콘솔에 로그를 출력하거나 다른 함수를 실행하는 역할만 하는 함수의 경우 특정 값을 반환하지 않는다.
function showModal(type: ModalType): void {
feedbackSlice.actions.createModal(type);
}
// 화살표 함수로 작성 시
const showModal = (type: ModalType): void => {
feedbackSlice.actions.createModal(type);
};
자바스크립트에서는 함수에서 명시적인 반환문을 작성하지 않으면 기본적으로 undefined가 반환된다. 하지만 타입스크립트에서는 void 타입이 사용되는데 이것은 undefined가 아니다.
void 타입은 주로 함수 반환 타입으로 사용되지만 사실 함수에 국한된 타입은 아니다. 아래처럼 변수에도 할당할 수 있지만 함수가 아닌 값에 대해서는 대부분 무의미하다. void 타입으로 지정된 변수는 undefined 또는 null 값만 할당할 수 있다.
그런데 만약 tsconfig.json에서 strictNullChecks 옵션이 설정되었거나 컴파일 시 해당 플래그 설정이 실행되는 경우에는 null 값을 할당할 수 없다. 또한 명시적인 의미를 부여하는 관점에서 undefined와 null 타입 키워드를 직접 사용해서 타입을 지정하는 것이 더 바람직하다.
let voidValue: void = undefined;
// strictNullChecks가 비활성화된 경우에 가능
voidValue = null;
일반적으로 함수 자체를 다른 함수의 인자로 전달하는 경우가 아니라면 void 타입은 잘 명시하지 않는 경향이 있다. 함수 내부에 별도 반환문이 없다면 타입스크립트 컴파일러가 알아서 함수 타입을 void로 추론해주기 때문이다.
1.4 never 타입
never 타입도 일반적으로 함수와 관련하여 많이 사용되는 타입이다. never 타입은 값을 반환할 수 없는 타입을 말한다. 여기서 값을 반환하지 않는 것과 반환할 수 없는 것을 명확히 구분해야 한다. 자바스크립트에서 값을 반환할 수 없는 예는 크게 2가지로 나눌 수 있다.
에러를 던지는 경우
자바스크립트에서는 런타임에 의도적으로 에러를 발생시키고 캐치할 수 있다. throw 키워드를 사용하면 에러를 발생시킬 수 있는데, 이는 값을 반환하는 것으로 간주하지 않는다. 따라서 특정 함수가 실행 중 마지막에 에러를 던지는 작업을 수행한다면 해당 함수의 반환 타입은 never이다.
function generateError(res: Response): never {
throw new Error(res.getMessage());
}
무한히 함수가 실행되는 경우
드물지만 함수 내에서 무한 루프를 실행하는 경우가 있을 수 있다. 무한 루프는 결국 함수가 종료되지 않음을 의미하기 때문에 값을 반환하지 못한다.
function checkStatus(): never {
while (true) {
// ...
}
}
never 타입은 모든 타입의 하위 타입이다. 즉, never 자신을 제외한 어떤 타입도 never 타입에 할당될 수 없다는 것을 의미한다. 심지어 any 타입이라 할지라도 never 타입에 할당될 수 없다.
따라서 타입스크립트에서는 조건부 타입을 결정할 때 특정 조건을 만족하지 않는 경우에 엄격한 타입 검사 목적으로 never 타입을 명시적으로 사용하기도 한다.
1.5 Array 타입
배열 타입을 가리키는 Array 키워드는 자바스크립트에서도 Object.prototype.toString.call(...) 연산자를 사용하여 확인할 수 있다. Object.prototype.toString.call(...) 함수는 객체의 타입을 알아내는 데 사용하는 함수이다. typeof를 사용하여 타입을 알 수도 있지만, 이 함수를 사용하는 이유는 typeof의 경우 객체 타입을 단순히 object 타입으로 알려주지만, Object.prototype.toString.call(...) 함수는 객체의 인스턴스까지 알려주기 때문이다.
const arr = [];
console.log(Object.prototype.toString.call(arr)); // '[object Array]'
이미 자바스크립트에서도 확인할 수 있는 자료형인데도, 타입스크립트에서 다시 Array를 언급하는 이유를 다음과 같이 제시할 수 있다.
- 엄밀히 말하면 자바스크립트에서는 배열을 객체에 속하는 타입으로 분류한다. 즉, 자바스크립트에서는 배열을 단독으로 배열이라는 자료형에 국한하지 않는다.
- 타입스크립트에서
Array라는 타입을 사용하기 위해서는 타입스크립트의 특수한 문법을 함께 다뤄야 한다.
배열은 Array 키워드 외에도 대괄호([])를 사용해서 직접 타입을 명시할 수도 있는데, 이때의 타입은 배열보다 더 좁은 범위인 튜플(Tuple)을 가리킨다.
자바스크립트의 배열은 동적 언어의 특징에 따라 어떤 값이든 배열의 원소로 허용한다. 즉, 하나의 배열로 선언된 변수에 숫자, 문자열, 객체 등 자료형이 무엇이든 상관없이 원소를 삽입하고 관리할 수 있다.
const fn = () => console.log(1);
const array = [1, "string", fn]; // 자바스크립트에서는 배열에 숫자, 문자열, 함수 등 다양한 값을 삽입할 수 있다
array[0]; // 1
array[1]; // string
array[2](); // 1
하지만 이런 개념은 타입스크립트의 정적 타이핑과 잘 부합하지 않는다. 비단 타입스크립트뿐 아니라 다른 정적 언어에서도 배열의 원소로 하나의 타입만 사용하도록 명시한다. 대개 정적 타입의 언어에서는 배열을 선언할 때 크기까지 동시에 제한하기도 한다.
타입스크립트에서는 일반적으로 배열의 크기까지 제한하지는 않지만 정적 타입의 특성을 살려 명시적인 타입을 선언하여 해당 타입의 원소를 관리하는 것을 강제한다.
const array: number[] = [1, 2, 3]; // 숫자에 해당하는 원소만 허용한다
자바스크립트에서 배열 타입을 Object.prototype.toString.call(...) 연산자로 확인해보면 Array가 반환된다. 타입스크립트에서는 이 키워드로 배열 타입을 선언하는 방법도 있다. 이를 위해 제네릭이라는 특수한 문법을 사용한다.
const array: Array<number> = [1, 2, 3];
// number[]와 동일한 타입이다
2가지 방식으로 배열 타입을 선언할 수 있는데 두 방식 간의 차이점은 선언하는 형식 외에는 없다. 개인의 선호나 팀의 컨벤션에 따라 하나의 방식으로 통일하거나 2가지 방식을 혼용해서 사용해도 큰 문제는 없다.
기본적으로 자바스크립트의 동작은 배열 원소의 타입을 구분하지 않기 때문에 다양한 자료형의 원소를 함께 다룰 수 있는데, 만약 숫자형과 문자열 등 여러 타입을 모두 관리해야 하는 배열을 선언하려면 유니온 타입을 사용할 수 있다.
const array1: Array<number | string> = [1, "string"];
const array2: number[] | string[] = [1, "string"];
// 후자의 방식은 아래와 같이 선언할 수도 있다
const array3: (number | string)[] = [1, "string"];
타입스크립트에서 배열 타입을 명시하는 것만으로 배열의 길이까지는 제한할 수 없다. 그러나 튜플은 배열 타입의 하위 타입으로 기존 타입스크립트의 배열 기능에 길이 제한까지 추가한 타입 시스템이라고 볼 수 있다.
튜플은 타입스크립트의 타입 시스템과 대괄호를 사용해서 선언할 수 있다. 대괄호 안에 타입 시스템을 기술하는 것이 배열 타입과 유일하게 다른 점이다. 이때 대괄호 안에 선언하는 타입의 개수가 튜플이 가질 수 있는 원소의 개수를 나타낸다. 즉, 튜플은 배열의 특정 인덱스에 정해진 타입을 선언하는 것과 같다.
let tuple: [number] = [1];
tuple = [1, 2]; // 불가능
tuple = [1, "string"]; // 불가능
let tuple: [number, string, boolean] = [1, "string", true]; // 여러 타입과 혼합도 가능하다
기본적으로 타입스크립트에서의 배열과 튜플은 자바스크립트와 달리 제한적으로 쓰인다. 배열은 사전에 허용하지 않은 타입이 서로 섞이는 것을 방지하여 타입 안정성을 제공한다. 튜플은 길이까지 제한하여 원소 개수와 타입을 보장한다. 이처럼 타입을 제한하는 것은 자바스크립트가 갖는 동적 언어의 자유로움으로 인해 발생할 수 있는 런타임 에러와 유지보수의 어려움을 막기 위한 것이다. 특히 튜플의 경우 컨벤션을 잘 지키고 각 배열 원소의 명확한 의미와 쓰임을 보장할 때 더욱 안전하게 사용할 수 있는 타입이다.
튜플의 유용한 쓰임새를 알아보기 위해 사용자 인터페이스를 만들기 위한 자바스크립트 라이브러리인 리액트 예시를 살펴보자. 리액트는 16.8 버전부터 도입된 훅(Hook)이라는 요소 중 useState는 튜플 타입을 반환한다. 첫 번째 원소는 훅으로부터 생성 및 관리되는 상태 값을 의미하고, 두 번째 원소는 해당 상태를 조작할 수 있는 세터(setter)를 의미한다.
useState API는 배열 원소의 자리마다 명확한 의미를 부여하기 때문에 컴포넌트에서 사용하지 않은 값에 접근하는 오류를 방지할 수 있다. 또한 구조 분해 할당을 사용해서 사용자가 자유롭게 이름을 정의할 수 있다.
import { useState } from "react";
const [value, setValue] = useState(false);
const [username, setUsername] = useState("");
useState는 반환 값이 명확하고 잘 설계된 API이기 때문에 튜플 타입을 통해 이와 같은 유연성을 얻을 수 있다. 첫 번째 원소와 두 번째 원소의 타입과 의미가 명확하기 때문에 사용자는 그 의미에 맞게 적합한 이름을 선언하여 값을 가져올 수 있다.
const useStateWithObject = (initialValue: any) => {
...
return { value, setValue };
};
const { value, setValue } = useStateWithObject(false); // 해당 함수에서 정의된 속성 이름으로 가져와야 한다
const { value: username, setValue: setUsername } = useStateWithObject(''); // 사용자 정의 이름으로 사용하고 싶다면 일차적으로 먼저 접근한 다음에 다른 이름으로 지정할 수 있다
튜플과 배열의 성질을 혼합해서 사용할 수도 있다. 다음과 같이 스프레드 연산자(...)를 사용하여 특정 인덱스에서 요소를 명확한 타입으로 선언하고 나머지 인덱스에서는 배열처럼 동일한 자료형의 원소를 개수 제한 없이 받도록 할 수 있다.
const httpStatusFromPaths: [number, string, ...string[]] = [
400,
"Bad Request",
"/users/:id",
"/users/:userId",
"/users/:uuid",
];
// 첫 번째 자리는 숫자(400), 두 번째 자리는 문자열('Bad Request')을 받아야 하고, 그 이후로는 문자열 타입의 원소를 개수 제한 없이 받을 수 있음
또한 옵셔널 프로퍼티(선택적 속성)를 명시하고 싶다면 물음표(?) 기호와 함께 해당 속성을 선언할 수 있다. 해당 원소는 옵셔널하기 때문에 해당 인덱스에 필수적으로 자리 잡고 있지 않을 수 있음을 의미한다.
const optionalTuple1: [number, number, number?] = [1, 2];
const optionalTuple2: [number, number, number?] = [1, 2, 3]; // 3번째 인덱스에 해당하는 숫자형 원소는 있어도 되고 없어도 됨을 의미한다
1.6 enum 타입
enum 타입은 열거형이라고도 부르는데 타입스크립트에서 지원하는 특수한 타입이다. enum은 일종의 구조체를 만드는 타입 시스템이다. enum을 사용해서 열거형을 정의할 수 있는데 열거형은 각각의 멤버를 가지고 있다. 이것은 자바스크립트 객체의 모양새와 닮았다. 다만 타입스크립트는 명명한 각 멤버의 값을 스스로 추론한다. 기본적인 추론 방식은 숫자 0부터 1씩 늘려가며 값을 할당하는 것이다.
enum ProgrammingLanguage {
Typescript, // 0
Javascript, // 1
Java, // 2
Python, // 3
Kotlin, // 4
Rust, // 5
Go, // 6
}
// 각 멤버에게 접근하는 방식은 자바스크립트에서 객체의 속성에 접근하는 방식과 동일하다
ProgrammingLanguage.Typescript; // 0
ProgrammingLanguage.Rust; // 5
ProgrammingLanguage["Go"]; // 6
// 또한 역방향으로도 접근이 가능하다
ProgrammingLanguage[2]; // "Java"
또한 각 멤버에 명시적으로 값을 할당할 수 있다. 모든 멤버에 일일이 값을 할당할 수도 있지만, 일부 멤버에 값을 직접 할당하지 않아도 타입스크립트는 누락된 멤버를 아래와 같은 방식으로 이전 멤버 값의 숫자를 기준으로 1씩 늘려가며 자동으로 할당한다.
enum ProgrammingLanguage {
Typescript = "Typescript",
Javascript = "Javascript",
Java = 300,
Python = 400,
Kotlin, // 401
Rust, // 402
Go, // 403
}
enum 타입은 주로 문자열 상수를 생성하는 데 사용된다. 이를 통해 응집력있는 집합 구조체를 만들 수 있으며, 사용자 입장에서도 간편하게 활용할 수 있다.
또한 열거형은 그 자체로 변수 타입으로 지정할 수 있다. 이때 열거형을 타입으로 가지는 변수는 해당 열거형이 가지는 모든 멤버를 값으로 받을 수 있다. 이런 특성은 코드의 가독성을 높여준다.
enum ItemStatusType {
DELIVERY_HOLD = "DELIVERY_HOLD", // 배송 보류
DELIVERY_READY = "DELIVERY_READY", // 배송 준비 중
DELIVERING = "DELIVERING", // 배송 중
DELIVERED = "DELIVERED", // 배송 완료
}
const checkItemAvailable = (itemStatus: ItemStatusType) => {
switch (itemStatus) {
case ItemStatusType.DELIVERY_HOLD:
case ItemStatusType.DELIVERY_READY:
case ItemStatusType.DELIVERING:
return false;
case ItemStatusType.DELIVERED:
default:
return true;
}
};
checkItemAvailable 함수의 인자인 itemStatus는 ItemStatusType 열거형을 타입으로 가진다. 이를 통해 얻을 수 있는 효과는 itemStatus의 타입이 문자열로 지정된 경우와 비교했을 때 다음과 같다.
- 타입 안정성:
ItemStatusType에 명시되지 않은 다른 문자열은 인자로 받을 수 없다. 따라서 타입 안정성이 우수하다. - 명확한 의미 전달과 높은 응집력:
ItemStatusType타입이 다루는 값이 무엇인지 명확하다. 아이템 상태에 대한 값을 모아놓은 것으로 응집력이 뛰어나다. - 가독성: 응집도가 높기 때문에 말하고자 하는 바가 더욱 명확하다. 따라서 열거형 멤버를 통해 어떤 상태를 나타내는지 쉽게 이해할 수 있다. (예:
ItemStatusType.DELIVERY_HOLDvsDELIVERY_HOL)
이처럼 열거형은 관련이 높은 멤버를 모아 문자열 상수처럼 사용하고자 할 때 유용하게 쓸 수 있다.
다만 열거형에 사용할 때는 주의해야 할 점이 있다. 먼저 숫자로만 이루어져 있거나 타입스크립트가 자동으로 추론한 열거형은 안전하지 않은 결과를 낳을 수 있다. 맨 처음 예시를 보면 역방향으로도 접근할 수 있음을 보여준다. 여기서 할당된 값을 넘어서는 범위로 역방향으로 접근하더라도 타입스크립트는 막지 않는다.
이러한 동작을 막기 위해 const enum으로 열거형을 선언하는 방법이 있다. 이 방식은 역방향으로의 접근을 허용하지 않기 때문에 자바스크립트에서의 객체에 접근하는 것과 유사한 동작을 보장한다.
ProgrammingLanguage[200]; // undefined를 출력하지만 별다른 에러를 발생시키지 않는다
// 다음과 같이 선언하면 위와 같은 문제를 방지할 수 있다
const enum ProgrammingLanguage {
// ...
}
그러나 const enum으로 열거형을 선언하더라도 숫자 상수로 관리되는 열거형은 선언한 값 이외의 값을 할당하거나 접근할 때 이를 방지하지 못한다. 반면 문자열 상수 방식으로 선언한 열거형은 미리 선언하지 않은 멤버로 접근을 방지한다. 따라서 문자열 상수 방식으로 열거형을 사용하는 것이 숫자 상수 방식보다 더 안전하며 의도하지 않은 값의 할당이나 접근을 방지하는 데 도움이 된다.
const enum NUMBER {
ONE = 1,
TWO = 2,
}
const myNumber: NUMBER = 100; // NUMBER enum에 정의되지 않은 값이지만, 숫자형 enum은 컴파일 시점에 값이 인라인 처리되어 타입 오류가 발생하지 않을 수 있다.
const enum STRING_NUMBER {
ONE = "ONE",
TWO = "TWO",
}
const myStringNumber: STRING_NUMBER = "THREE"; // Error
이외에도 열거형의 가장 큰 문제는 따로 존재한다. 앞서 열거형은 타입 공간과 값 공간에서 모두 사용된다고 말했다. 해당 예시에서 열거형은 타입스크립트 코드가 자바스크립트로 변환될 때 즉시 실행 함수(IIFE) 형식으로 변환되는 것을 볼 수 있다.
이때 일부 번들러에서 트리쉐이킹 과정 중 즉시 실행 함수로 변환된 값을 사용하지 않는 코드로 인식하지 못하는 경우가 발생할 수 있다. 따라서 불필요한 코드의 크기가 증가하는 결과를 초래할 수 있다. 이러한 문제를 해결하기 위해 앞서 언급했던 const enum 또는 as const assertion을 사용해서 유니온 타입으로 열거형과 동일한 효과를 얻는 방법이 있다.
2. 타입 조합
2.1 교차 타입 (Intersection)
교차 타입을 사용하면 여러 가지 타입을 결합하여 하나의 단일 타입으로 만들 수 있다. 다시 말해 기존에 존재하는 다른 타입들을 합쳐서 해당 타입의 모든 멤버를 가지는 새로운 타입을 생성하는 것이다. 교차 타입은 &을 사용해서 표기한다. 결과물로 탄생한 단일 타입에는 타입 별칭(type alias)을 붙일 수도 있다.
예를 들어, 타입 C가 타입 A와 B의 교차 타입 즉, A & B라면 타입 C는 타입 A와 타입 B의 모든 멤버를 가지고 있는 타입이다. 당연히 2개의 타입뿐만 여러 개의 타입을 교차시킬 수도 있다.
type ProductItem = {
id: number;
name: string;
type: string;
price: number;
imageUrl: string;
quantity: number;
};
type ProductItemWithDiscount = ProductItem & { discountAmount: number };
2.2 유니온 타입 (Union)
유니온 타입은 타입 A 또는 타입 B 중 하나가 될 수 있는 타입을 말하며 A | B같이 표기한다. 주로 특정 변수가 가질 수 있는 타입을 전부 나열하는 용도로 사용된다. 교차 타입과 마찬가지로 2개 이상의 타입을 이어 붙일 수 있고 타입 별칭을 통해 중복을 줄일 수도 있다. 아래 예시는 ProductItem 혹은 CardItem이 될 수 있는 유니온 타입인 PromotionEventItem을 나타낸다. 즉, 이벤트 프로모션의 대상으로 상품이 될 수도 있고 카드가 될 수도 있다는 의미이다.
아래 printPromotionItem() 함수를 보면 인자로 PromotionEventItem 타입을 받고 있다. 해당 함수 내부에서 quantity를 참조하려고 시도하면 컴파일 에러가 발생하는데, 이는 quantity가 ProductItem에만 존재하기 때문이다. PromotionEventItem은 CardItem도 포함하는데 CardItem은 quantity 멤버를 가지고 있지 않기 때문에 PromotionEventItem에서는 quantity를 참조할 수 없다.
type CardItem = {
id: number;
name: string;
type: string;
imageUrl: string;
};
type PromotionEventItem = ProductItem | CardItem;
const printPromotionItem = (item: PromotionEventItem) => {
console.log(item.name); // O
console.log(item.quantity); // 컴파일 에러 발생
};
참고로 교차 타입과 유니온 타입은 여러 줄에 걸쳐 표기할 수도 있는데, 이럴 경우에는 각 줄의 맨 앞에 & 혹은 |를 붙여서 표기하면 된다.
type PromotionEventItem =
| ProductItem
| CardItem;
2.3 인덱스 시그니처 (Index Signatures)
인덱스 시그니처는 특정 타입의 속성 이름은 알 수 없지만 속성값의 타입을 알고 있을 때 사용하는 문법이다. 인터페이스 내부에 [Key: K]: T 꼴로 타입을 명시해주면 되는데 이는 해당 타입의 속성 키는 모두 K 타입이어야 하고 속성값은 모두 T 타입을 가져야 한다는 의미다.
interface IndexSignatureEx {
[key: string]: number;
}
인덱스 시그니처를 선언할 때 다른 속성을 추가로 명시해줄 수 있는데 이때 추가로 명시된 속성은 인덱스 시그니처에 포함되는 타입이어야 한다. 아래 예시의 name은 string 타입을 가지도록 선언되어 있지만, 인덱스 시그니처의 키가 string일 때는 number | boolean 타입이 오게끔 선언되어 있기 때문에 에러가 발생한다.
interface IndexSignatureEx2 {
[key: string]: number | boolean;
length: number;
isValid: boolean;
name: string; // 에러 발생
}
2.4 인덱스드 엑세스 타입 (Indexed Access Types)
인덱스드 엑세스 타입은 다른 타입의 특정 속성이 가지는 타입을 조회하기 위해 사용된다. 아래 첫 번째 예시(IndexedAccess)는 Example 타입의 a 속성이 가지는 타입을 조회하기 위한 인덱스드 엑세스 타입이다. 인덱스에 사용되는 타입 또한 그 자체로 타입이기 때문에 유니온 타입, keyof, 타입 별칭 등의 표현을 사용할 수 있다.
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 PromotionItemType = { type: string; name: string }
type PromotionItemType = ElementOf<PromotionList>;
2.5 맵드 타입 (Mapped Types)
자바스크립트의 map은 배열 A를 기반으로 새로운 배열 B를 만들어내는 배열 메서드이다. 이와 마찬가지로 맵드 타입은 다른 타입을 기반으로 한 타입을 선언할 때 사용하는 문법인데, 인덱스 시그니처 문법을 사용해서 반복적인 타입 선언을 효과적으로 줄일 수 있다.
type Example = {
a: number;
b: string;
c: boolean;
};
type Subset<T> = {
[K in keyof T]?: T[K];
};
const aExample: Subset<Example> = { a: 3 };
const bExample: Subset<Example> = { b: "hello" };
const acExample: Subset<Example> = { a: 4, c: true };
맵드 타입에서 매핑할 때는 readonly와 ?를 수식어로 적용할 수 있다. 맵드 타입의 특이한 점은 이러한 수식어를 더해주는 것뿐만 아니라 제거할 수도 있다는 것이다. 기존 타입에 존재하던 readonly나 ? 앞에 -를 붙여주면 해당 수식어를 제거한 타입을 선언할 수 있다.
type ReadOnlyEx = {
readonly a: number;
readonly b: string;
};
type CreateMutable<Type> = {
-readonly [Property in keyof Type]: Type[Property];
};
type ResultType = CreateMutable<ReadOnlyEx>; // { a: number; b: string }
type OptionalEx = {
a?: number;
b?: string;
c: boolean;
};
type Concrete<Type> = {
[Property in keyof Type]-?: Type[Property];
};
type ResultType = Concrete<OptionalEx>; // { a: number; b: string; c: boolean }
맵드 타입이 실제로 사용된 예시를 살펴보자. 배달의민족 선물하기 서비스에는 '바텀시트'라는 컴포넌트가 존재한다. 밑에서부터 스르륵 올라오는 모달이라고 생각하면 되는데 이 바텀시트는 선물하기 서비스의 최근 연락처 목록, 카드 선택, 상품 선택 등 여러 지면에서 사용되고 있다. 바텀시트마다 각각 resolver, isOpened 등의 상태를 관리하는 스토어가 필요한데 이 스토어의 타입(BottomSheetStore)을 선언해줘야 한다.
이때 BottomSheetMap에 존재하는 모든 키에 대해 일일이 스토어를 만들어줄 수도 있지만 불필요한 반복이 발생하게 된다. 이럴 때는 인덱스 시그니처 문법을 사용해서 BottomSheetMap을 기반으로 각 키에 해당하는 스토어를 선언할 수 있다. 이처럼 반복 작업을 효율적으로 처리할 수 있다.
const BottomSheetMap = {
RECENT_CONTACTS: RecentContactsBottomSheet,
CARD_SELECT: CardSelectBottomSheet,
SORT_FILTER: SortFilterBottomSheet,
PRODUCT_SELECT: ProductSelectBottomSheet,
REPLY_CARD_SELECT: ReplyCardSelectBottomSheet,
RESEND: ResendBottomSheet,
STICKER: StickerBottomSheet,
BASE: null,
};
export type BOTTOM_SHEET_ID = keyof typeof BottomSheetMap;
// 불필요한 반복이 발생한다
type BottomSheetStore = {
RECENT_CONTACTS: {
resolver?: (payload: any) => void;
args?: any;
isOpened: boolean;
};
CARD_SELECT: {
resolver?: (payload: any) => void;
args?: any;
isOpened: boolean;
};
SORT_FILTER: {
resolver?: (payload: any) => void;
args?: any;
isOpened: boolean;
};
// ...
};
// Mapped Types를 통해 효율적으로 타입을 선언할 수 있다
type BottomSheetStore = {
[index in BOTTOM_SHEET_ID]: {
resolver?: (payload: any) => void;
args?: any;
isOpened: boolean;
};
};
덧붙여 맵드 타입에서는 as 키워드를 사용하여 키를 재지정할 수 있다. 앞서 봤던 바텀시트를 다시 살펴보자. BottomSheetStore의 키 이름에 BottomSheetMap의 키 이름을 그대로 쓰고 싶은 경우가 있을 수 있고, 모든 키에 _BOTTOM_SHEET를 붙이는 식으로 공통된 처리를 적용하여 새로운 키를 지정하고 싶을 수도 있다. 이럴 때는 아래 예시처럼 as 키워드를 사용해서 효율적으로 처리할 수 있다.
type BottomSheetStore = {
[index in BOTTOM_SHEET_ID as `${index}_BOTTOM_SHEET`]: {
resolver?: (payload: any) => void;
args?: any;
isOpened: boolean;
};
};
2.6 템플릿 리터럴 타입 (Template Literal Types)
템플릿 리터럴 타입은 자바스크립트의 템플릿 리터럴 문자열을 사용하여 문자열 리터럴 타입을 선언할 수 있는 문법이다. 위에서 본 BottomSheetMap의 각 키에다 _BOTTOM_SHEET를 붙여주는 예시가 바로 템플릿 리터럴 타입을 활용한 것이다. 조금 더 간단한 활용 예시를 살펴보자.
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'
Stage 타입의 모든 유니온 멤버 뒤에 -stage를 붙여서 새로운 유니온 타입을 만들었다. 여기서 볼 수 있듯이 템플릿 리터럴을 사용하여 `${Stage}-stage`와 같이 변수 자리에 문자열 리터럴의 유니온 타입인 Stage를 넣으면 해당 유니온 타입(Stage 타입) 멤버들이 차례대로 해당 변수에 들어가서 -stage가 붙은 문자열 리터럴의 유니온 타입을 결과로 반환한다. 즉, Stage 타입의 각 멤버에 -stage를 추가하여 init-stage, select-image-stage, edit-image-stage, decorate-card-stage, capture-image-stage와 같은 새로운 문자열 리터럴 유니온 타입을 만들어냈다.
2.7 제네릭 (Generic)
제네릭은 C나 자바 같은 정적 언어에서 다양한 타입 간에 재사용성을 높이기 위해 사용하는 문법이다. 타입스크립트도 정적 타입을 가지는 언어이기 때문에 제네릭 문법을 지원하고 있다.
제네릭의 사전적 의미를 찾아보면 특징이 없거나 일반적인 것(not specific, general)을 뜻한다. 타입스크립트의 제네릭도 이와 비슷한 맥락을 가지고 있는데 한마디로 일반화된 데이터 타입이라고 할 수 있다. 좀 더 자세히 타입스크립트 제네릭의 개념을 풀어보면 함수, 타입, 클래스 등에서 내부적으로 사용할 타입을 미리 정해두지 않고 타입 변수를 사용해서 해당 위치를 비워 둔 다음에, 실제로 그 값을 사용할 때 외부에서 타입 변수 자리에 타입을 지정하여 사용하는 방식을 말한다.
이렇게 하면 함수, 타입, 클래스 등 여러 타입에 대해 하나하나 따로 정의하지 않아도 되기 때문에 재사용성이 크게 향상된다. 타입 변수는 일반적으로 <T>와 같이 꺾쇠괄호 내부에 정의되며, 사용할 때는 함수에 매개변수를 넣는 것과 유사하게 원하는 타입을 넣어주면 된다. 보통 타입 변수명으로 T(Type), E(Element), K(Key), V(Value) 등 한 글자로 된 이름을 많이 사용한다.
type ExampleArrayType<T> = T[];
const array1: ExampleArrayType<string> = ["치킨", "피자", "우동"];
앞서 제네릭이 일반화된 데이터 타입을 말한다고 했는데, 이 표현만 보면 any의 쓰임과 혼동할 수도 있을 것이다. 제네릭은 any처럼 아무 타입이나 무분별하게 받는 게 아니라, 배열 생성 시점에 원하는 타입으로 특정할 수 있다. 다시 말해 제네릭을 사용하면 배열 요소가 전부 동일한 타입이라고 보장할 수 있다.
type ExampleArrayType2 = any[];
const array2: ExampleArrayType2 = [
"치킨",
{
id: 0,
name: "치킨",
price: 20000,
quantity: 1,
},
99,
true,
];
참고로 제네릭 함수를 호출할 때 반드시 꺾쇠괄호(<>) 안에 타입을 명시해야 하는 것은 아니다. 타입을 명시하는 부분을 생략하면 컴파일러가 인수를 보고 타입을 추론해준다. 따라서 타입 추론이 가능한 경우에는 타입 명시를 생략할 수 있다.
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;
}
다시 언급하지만 제네릭은 일반화된 데이터 타입을 의미한다고 했다. 따라서 함수나 클래스 등의 내부에서 제네릭을 사용할 때 어떤 타입이든 될 수 있다는 개념을 알고 있어야 한다. 특정한 타입에서만 존재하는 멤버를 참조하려고 하면 안된다.
예를 들어 배열에만 존재하는 length 속성을 제네릭에서 참조하려고 하면 당연히 에러가 발생한다. 컴파일러는 어떤 타입이 제네릭에 전달될지 알 수 없기 때문에 모든 타입이 length 속성을 사용할 수는 없다고 알려주는 것이다.
function exampleFunc2<T>(arg: T): number {
return arg.length; // 에러 발생: Property 'length' does not exist on type 'T'
}
이럴 때는 제네릭 꺾쇠괄호 내부에 'length' 속성을 가진 타입만 받는다'라는 제약을 걸어줌으로써 length 속성을 사용할 수 있게끔 만들 수 있다.
interface TypeWithLength {
length: number;
}
function exampleFunc2<T extends TypeWithLength>(arg: T): number {
return arg.length;
}
제네릭을 사용할 때 주의해야 할 점이 있다. 파일 확장자가 tsx일 때 화살표 함수에 제네릭을 사용하면 에러가 발생한다. tsx는 타입스크립트 + JSX이므로 제네릭의 꺾쇠괄호와 태그의 꺾쇠괄호를 혼동하여 문제가 생기는 것이다.
이러한 상황을 피하기 위해서는 제네릭 부분에 extends 키워드를 사용하여 컴파일러에게 특정 타입의 하위 타입만 올 수 있음을 확실히 알려주면 된다. 보통 제네릭을 사용할 때는 function 키워드로 선언하는 경우가 많다.
// 에러 발생: JSX element 'T' has no corresponding closing tag
const arrowExampleFunc = <T>(arg: T): T[] => {
return new Array(3).fill(arg);
};
// 에러 발생 X
const arrowExampleFunc2 = <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):
Repository<T> {
return getConnection("ro").getRepository(target);
}
3.2 호출 시그니처의 제네릭
호출 시그니처(call signature)는 타입스크립트의 함수 타입 문법으로 함수의 매개변수와 반환 타입을 미리 선언하는 것을 말한다. 호출 시그니처를 사용함으로써 개발자는 함수 호출 시 필요한 타입을 별도로 지정할 수 있게 된다. 호출 시그니처를 사용할 때 제네릭 타입을 어디에 위치시키는지에 따라 타입의 범위와 제네릭 타입을 언제 구체 타입으로 한정할지를 결정할 수 있다.
interface useSelectPaginationProps<T> {
categoryAtom: RecoilState<number>;
filterAtom: RecoilState<string[]>;
sortAtom: RecoilState<SortType>;
fetcherFunc: (props: CommonListRequest) => Promise<DefaultResponse<ContentListResponse<T>>>;
}
이 코드는 우아한형제들 배민선물하기팀의 호출 시그니처 제네릭 활용 예시다. 여기서 <T>는 useSelectPaginationProps의 타입 별칭으로 한정했다. 따라서 useSelectPaginationProps을 사용할 때 타입을 명시함으로써 제네릭 타입을 구체 타입으로 한정한다. 설명을 덧붙이면 useSelectPaginationProps가 사용되는 useSelectPagination 훅의 반환 값도 인자에서 쓰는 제네릭 타입인 T와 연관 있기 때문에 이처럼 작성했다.
그다음 배민커머스웹프론트개발팀의 활용 예시를 보자.
export type UseRequesterHookType = <RequestData = void, ResponseData = void>(
baseURL?: string | Headers,
defaultHeader?: Headers
) => [RequestStatus, Requester<RequestData, ResponseData>];
이 예시에서 <RequestData, ResponseData>는 호출 시그니처의 일부, 다시 말해 괄호(()) 앞에 선언했기 때문에 타입스크립트는 UseRequesterHookType 타입의 함수를 실제 호출할 때 제네릭 타입을 구체 타입으로 한정한다.
배민커머스웹프론트개발팀은 프로젝트 구조를 따르기 위해 아래처럼 작성했다.
function useSelectPagination<T extends CardListContent | CommonProductResponse>({
categoryAtom,
filterAtom,
sortAtom,
fetcherFunc,
}: useSelectPaginationProps<T>): {
intersectionRef: RefObject<HTMLDivElement>;
data: T[];
categoryId: number;
isLoading: boolean;
isEmpty: boolean;
} {
// ...
return {
intersectionRef,
data: swappedData ?? [],
isLoading,
categoryId,
isEmpty,
};
}
3.3 제네릭 클래스
제네릭 클래스는 외부에서 입력된 타입을 클래스 내부에 적용할 수 있는 클래스이다. 제네릭 클래스는 다음과 같은 형태로 선언된다.
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, tables: [{ name: TABLE_NAME,
keyPath: "key" }] });
}
return this._DB;
}
// ...
}
클래스 이름 뒤에 타입 매개변수인 <T>를 선언해준다. <T>는 메서드의 매개변수나 반환 타입으로 사용될 수 있다. LocalDB 클래스는 외부에서 { key: string; value: Promise<Record<string, unknown>>; cacheTTL: number } 타입을 받아들여 클래스 내부에서 사용될 제네릭 타입으로 결정된다.
제네릭 클래스를 사용하면 클래스 전체에 걸쳐 타입 매개변수가 적용된다. 특정 메서드만을 대상으로 제네릭을 적용하려면 해당 메서드를 제네릭 메서드로 선언하면 된다.
3.4 제한된 제네릭
타입스크립트에서 제한된 제네릭은 타입 매개변수에 대한 제약 조건을 설정하는 기능을 말한다. 타입 매개변수 T 타입을 제약하는 방법을 알아보자.
예를 들어 string 타입으로 제약하려면 타입 매개변수는 특정 타입을 상속(extends)해야 한다.
type ErrorRecord<Key extends string> = Exclude<Key, ErrorCodeType> extends never
? Partial<Record<Key, boolean>>
: never;
이처럼 타입 매개변수가 특정 타입으로 묶였을 때(bind) 키를 바운드 타입 매개변수(bounded type parameters)라고 부른다. 그리고 string을 키의 상한 한계(upper bound)라고 한다.
상속받을 수 있는 타입으로는 기본 타입뿐만 아니라 상황에 따라 인터페이스나 클래스도 사용할 수 있다. 또한 유니온 타입을 상속해서 선언할 수도 있다.
function useSelectPagination<T extends CardListContent | CommonProductResponse>({
filterAtom,
sortAtom,
fetcherFunc,
}: useSelectPaginationProps<T>): {
intersectionRef: RefObject<HTMLDivElement>;
data: T[];
categoryId: number;
isLoading: boolean;
isEmpty: boolean;
} {
// ...
}
// 사용하는 쪽 코드
const { intersectionRef, data, isLoading, isEmpty } = useSelectPagination<CardListContent>({
categoryAtom: replyCardCategoryIdAtom,
filterAtom: replyCardFilterAtom,
sortAtom: replyCardSortAtom,
fetcherFunc: fetchReplyCardListByThemeGroup,
});
3.5 확장된 제네릭
제네릭 타입은 여러 타입을 상속받을 수 있으며 타입 매개변수를 여러 개 둘 수도 있다.
<Key extends string>
타입을 이런 식으로 제약해버리면 제네릭의 유연성을 잃어버린다. 제네릭의 유연성을 잃지 않으면서 타입을 제약해야 할 때는 타입 매개변수에 유니온 타입을 상속해서 선언하면 된다.
<Key extends string | number>
유니온 타입으로 T가 여러 타입을 받게 할 수는 있지만, 타입 매개변수가 여러 개일 때는 처리할 수 없다. 이럴 때는 매개변수를 하나 더 추가하여 선언한다.
다음은 Ok 타입이나 Err 타입을 매개변수 인자로 받아 사용하는 예시이다.
export class APIResponse<Ok, Err = string> {
private readonly data: Ok | Err | null;
private readonly status: ResponseStatus;
private readonly statusCode: number | null;
constructor(
data: Ok | Err | null,
statusCode: number | null,
status: ResponseStatus
) {
this.data = data;
this.status = status;
this.statusCode = statusCode;
}
public static Success<T, E = string>(data: T): APIResponse<T, E> {
return new this<T, E>(data, 200, ResponseStatus.SUCCESS);
}
public static Error<T, E = unknown>(init: AxiosError): APIResponse<T, E> {
if (!init.response) {
return new this<T, E>(null, null, ResponseStatus.CLIENT_ERROR);
}
if (!init.response.data?.result) {
return new this<T, E>(
null,
init.response.status,
ResponseStatus.SERVER_ERROR
);
}
return new this<T, E>(
init.response.data.result,
init.response.status,
ResponseStatus.FAILURE
);
}
// ...
}
// 사용하는 쪽 코드
const fetchShopStatus = async (): Promise
APIResponse<IShopResponse | null>
> => {
// ...
return (await API.get<IShopResponse | null>("/v1/main/shop", config)).map(
(it) => it.result
);
};
3.6 제네릭 예시
제네릭의 장점은 다양한 타입을 받게 함으로써 코드를 효율적으로 재사용할 수 있는 것이다. 그렇다면 실제 현업에서 가장 많이 제네릭이 활용할 때는 언제일까? 바로 API 응답 값의 타입을 지정할 때이다.
우아한형제들에서는 API 응답 값의 타입을 지정할 때 제네릭을 활용하여 적절한 타입 추론과 코드의 재사용성을 높이고 있다.
export interface MobileApiResponse<Data> {
data: Data;
statusCode: string;
statusMessage?: string;
}
이 코드를 살펴보면 API 응답 값에 따라 달라지는 data를 제네릭 타입 Data로 선언하고 있다. 이렇게 만든 MobileApiResponse는 실제 API 응답 값의 타입을 지정할 때 아래와 같이 사용되고 있다.
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,
});
};
이처럼 다양한 API 응답 값의 타입에 MobileApiResponse을 활용해서 코드를 효율적으로 재사용할 수 있다.
이런 식으로 제네릭을 필요한 곳에 사용하면 가독성을 높이고 코드를 효율적으로 작성할 수 있다. 하지만 굳이 필요하지 않은 곳에 제네릭을 사용하면 오히려 독이 되어 코드를 복잡하게 만든다.
제네릭을 굳이 사용하지 않아도 되는 타입
제네릭이 필요하지 않을 때도 사용하면 코드 길이만 늘어나고 가독성을 해칠 수 있다. 다음은 제네릭이 굳이 필요하지 않은데도 사용한 예시다.
type GType<T> = T;
type RequirementType = "USE" | "UN_USE" | "NON_SELECT";
interface Order {
getRequirement(): GType<RequirementType>;
}
GType이 다른 곳에서는 사용되지 않고 getRequirement 함수의 반환 값 타입으로만 사용되고 있다고 가정해보자.
GType이라는 이름이 현재 사용되고 있는 목적의 의미를 정확히 담고 있지도 않을뿐더러 굳이 제네릭을 사용하지 않고 타입 매개변수를 그대로 선언하는 것과 같은 기능을 하고 있다.
즉, 아래처럼 사용하는 것과 동일하다.
type RequirementType = "USE" | "UN_USE" | "NON_SELECT";
interface Order {
getRequirement(): RequirementType;
}
any 사용하기
제네릭은 코드의 재사용성을 높이고 타입 추론을 하는 데 사용된다. 그러나 any를 사용하면 제네릭의 장점과 타입 추론 및 타입 검사를 할 수 있는 이점을 누릴 수 없게 된다. any 타입은 모든 타입을 허용하기 때문에 사실상 자바스크립트와 동일한 방식으로 코드를 작성하는 것과 같다. 따라서 any를 사용하면 제네릭을 포함해 타입을 지정하는 의미가 사라지게 된다.
type ReturnType<T = any> = {
// ...
};
가독성을 고려하지 않은 사용
제네릭이 과하게 사용되면 가독성을 해치기 때문에 코드를 읽고 타입을 이해하기가 어려워진다. 부득이한 상황을 제외하고 복잡한 제네릭은 의미 단위로 분할해서 사용하는 게 좋다.
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>;
만약에 내가 작성한 코드를 다른 개발자가 쉽게 이해하지 못하고 있다면 혹시 제네릭을 오남용하고 있는 것은 아닌지 검토해봐야 한다.