
- #typescript
타입스크립트 - 고급 타입 정리하기
Prolip
2024-11-29
인덱스 시그니처, 맵드 타입, 템플릿 리터럴, 제네릭 등 타입스크립트의 고급 타입 알아보기
any 타입
any 타입은 자바스크립트에 존재하는 모든 값을 받을 수 있다. string, number, 중첩 구조의 함수까지 그냥 타입을 명시하지 않은 자바스크립트와 동일한 효과를 나타낸다.
let state: any; state = { value: 0 }; // 객체 state = 100; // 숫자 state = "jimin"; // 문자열 state.foo.bar = () => console.log("jimin"); // 중첩 구조로 들어가 함수 // 모두 OK
위와 같이 any 타입은 타입스크립트로 달성하고자 하는 정적 타이핑을 무색하게 만든다.
척 봐도 any 타입을 사용하지 않는 것이 좋은 습관일 것 같은데, 어쩔 수 없이 사용해야 할 때가 대표적으로 3개 존재한다.
1. 매우 복잡한 구성 요소로 이루어진 개발 과정에서 추후 값이 변경될 가능성이 있거나 아직 세부 항목에 대한 타입이 확정되지 않은 경우.
하지만 이는 세부 스펙이 나올 경우 해당 스펙에 맞게 타입을 재지정해야만 한다. 만약 누락될 경우 큰 문제가 발생하니 누락되지 않게 주의해야 한다.
2. 외부 라이브러리, API 요청 등 어떤 인자를 주고받을지 특정하기 힘들 때.
예를 들어 모달 창을 그릴 때 실행될 함수를 인자로 전달 받는다고 가정해보자.
모달은 재사용성이 높아 어떤 곳에서 사용될지 특정할 수 없다. 이 때 타입을 일일이 명시하기 힘들 경우 any 타입을 사용할 수 있다.
3. 값을 예측할 수 없을 때 암묵적으로 사용.
위와 같이 3가지 경우를 예로 들 수 있겠으나, 타입스크립트의 타입 검사와 위험 상황을 초래하고 싶지 않으니 최대한 지양하자..
unknown 타입
위에서 살펴본 any 타입과 유사하게 모든 타입의 값이 할당될 수 있다. 하지만 any를 제외한 다른 타입으로 선언된 변수에는 unknown 타입 값을 할당할 수 있다.
let unknownValue: unknown; unknownValue = 100; // 숫자 unknownValue = "jimin"; // 문자열 unknownValue = () => console.log("jimin"); // 함수 // 모두 OK let someValue: any = unknownValue; // OK let someValue2: number = unknownValue; // X let someValue3: number = unknownValue; // X // any 타입으로 선언된 변수를 제외한 다른 변수에는 unknown 타입의 값을 할당할 수 없다.
어쨌든 unknown 타입도 any 타입과 비슷하게 결국 모든 타입의 값을 할당할 수 있다.
그럼 any랑 똑같은 거 아닌가? 싶을 수도 있지만, any는 나는 아무거나 다 가능해요! 라면 unknown 타입은 뭐가 들어올지 알 수 없어요! 꼭 확인하세요! 의 느낌이다.
let anything: any; let unknownValue: unknown; function 무언가 리소스를 받는 함수(어떤 인자) { // 무언가 처리를 함. anything = 155; unknownValue = "jimin"; } anything.length; // OK .... but,, unknownValue.length; // No
실제로 이런 코드는 없겠지만.. 이 코드를 보고 이해해보자.
anything과 unknownValue는 어떤 함수를 통해 각각 숫자와 문자열 값을 받는다.
anything엔 숫자 값이 할당되어있다. number 타입의 값에 length 속성을 참조하려고 하면 당연히 에러가 발생할 것이다.
하지만 any 타입으로 선언된 anything은 타입스크립트가 컴파일 타임에 에러를 잡아내지 못한다. 그럼 개발 과정에서 에러를 확인하지 못하고 런타임 환경에서 에러가 발생할 것이다.
반면, unknown 타입에 length 속성을 참조하려고 시도하면 타입스크립트는 이를 감지하고 에러를 표시한다. 하지만 아래와 같이 타입을 검사한 뒤엔 length 속성을 참조할 수 있다.
function isString(value: unknown): value is string { return typeof value === "string"; } if (isString(unknownValue)) { console.log(unknownValue.length); // OK }
여기서 확연히 any 타입과 다른 점을 보여주는데, unknown 타입은 확실히 어떤 값이든 올 수 있지만 동시에 개발자에게 엄격한 타입 검사를 강제하는 의도를 담고 있다.
개발 과정에서 any 타입을 사용한다면, 임시적으로 문제를 회피할 수는 있겠지만 결국 이후에 특정 타입으로 수정하는 과정이 누락된다면 런타임 환경에서 예상치 못한 버그가 발생할 가능성이 높다.
데이터 구조를 파악하기 힘들 때 any 타입 대신 unknown 타입을 사용한다면, 확실히 엄격한 타입 검사를 거쳐야 값을 사용할 수 있기 때문에 보다 안전하게 개발할 수 있다.. any 대신 unknown을 쓰자.
void 타입
function something() { console.log("hi"); } const somethingValue = something(); console.log(somethingValue); // undefined
이렇게 함수 내부에서 반환 값이 없는 함수를 할당한 변수를 출력해보면 undefined가 출력된다. 이렇게 명시적으로 어떤 값을 반환하지 않을 때 void를 지정해서 사용한다.
근데 굳이 함수 자체를 다른 함수의 인자로 전달하는 경우가 아니라면 void를 명시하지 않고, 타입스크립트 컴파일러가 알아서 추론해준다.
never 타입
never 타입은 말 그대로 값을 반환할 수 없는 타입이다. 값을 반환하지 않는 것 과 값을 반환하지 못하는 것을 잘 구분해야 한다.
이 값을 반환하지 못하는 경우는 대표적으로 2가지로 나누는데,
1. 에러를 던지는 경우
function generateError(res: Response): nver { throw new Error(res.getMessage()); }
자바스크립트에선 의도적으로 에러를 발생시키고 catch를 사용해 이를 잡아낼 수 있다. throw 키워드로 에러를 발생시키는데, 이는 값을 반환하는 것으로 간주하지 않는다.
2. 무한히 함수가 실행되는 경우
function checkStatus(): never { while (true) { // ... } }
위와 같이 함수가 끝나지 않는 경우 당연히 값을 반환할 수 없다.
이렇게 대표적인 2가지로 never 타입을 명시하기도 하며, 조건부 타입을 결정할 때 특정 조건을 만족하지 않는 경우에 엄격한 타입 검사 목적으로 never 타입을 명시적으로 사용한다.
이는 never 타입이 모든 타입의 하위 타입이기 때문에 어떤 타입도 never 자신을 제외한 어떤 타입도 할당될 수 없기 때문에 가능하다.
Array 타입
자바스크립트는 배열을 다음과 같이 사용할 수 있다.
const arr = [1, "string", { name: "jimin" }];
정말 근본 없이 숫자, 문자열, 객체 등 모든 자료형을 담을 수 있다.
하지만 타입스크립트뿐만 아니라 다른 정적 언어에선 배열을 선언할 때 원소로 하나의 자료형만 담을 수 있게 명시하며, 크기까지 제한하기도 한다.
타입스크립트는 크기를 제한하진 않지만 정적 타입의 특성을 살려 명시적인 타입 선언을 통해 해당 타입의 원소를 관리하는 것을 강제한다.
const arr: number[] = [1, 2, 3]; // 숫자만 가능 const arr2: Array<string> = ["hi", "hello", "jimin"]; // 문자열만 가능 // 만약 여러 타입을 모두 관리해야한다면? const arr3: (number | string)[] = [1, "hi", 3]; // 유노이노 타입도 가능 const arr4: Array<number | string>[] = [4, "jimin", "hello"]; // 제네릭으로도 가능
튜플
위에서 살펴본 Array 타입은 길이를 제한하진 않는다.
그러나 튜플은 배열 타입의 하위 타입으로 기존 타입스크립트의 배열 기능에 길이 제한까지 추가한 타입 시스템으로 볼 수 있다.
let tuple: [number] = [1]; tuple = [1, 3]; // No. tuple = [1, "string"]; // No. let tuple2: [number, string, boolean] = [1, "string", true]; // 여러 타입도 가능
위와 같이 사전에 허용한 타입의 값만 들어올 수 있으며, 길이까지 제한해 보다 안전하게 사용할 수 있다.
enum 타입
enum은 열거형이라고도 부르는데 타입스크립트에서 지원하는 특수한 타입이다.
enum ProgrammingLanguage { Typescript, // 0 Javascript, // 1 Java, // 2 Python, // 3 } ProgrammingLanguage.Typescript; // 0 ProgrammingLanguage["Java"]; // 2 ProgrammingLanguage[1]; // Javascript
위의 예시와 같이 지 혼자 각 멤버의 값을 추론한다.
enum ProgrammingLanguage { Typescript = "Typescript", Javascript = "Javascript", Java = 300, Python = 400, Kotlin, // 401 Rust, // 402 }
위와 같이 숫자를 명시하다 말면, 끊긴 이후부터 혼자 또 추론한다.
보통 enum은 문자열 상수를 생성하는 데 사용된다. 위와 같이 ProgrammingLanguage와 같이 각 멤버가 프로그래밍 언어와 관련된 값을 다룬다는 것을 파악할 수 있듯, 응집력 있는 집합 구조체를 만들 수 있다.
또, 열거형은 그 자체로 변수 타입을 지정할 수 있는데 열거형을 타입으로 가지는 변수는 해당 열거형이 가지는 모든 멤버를 값으로 받을 수 있다.
enum ItemStatusType { DELIVERY_HOLD = "DELIVERY_HOLD", DELIVERY_READY = "DELIVERY_READY", DELIVERING = "DELIVERING", DELIVERED = "DELIVERED", } function 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 열거형을 타입으로 가진다.
딱 봤을 때 ItemStatusType에 명시되지 않은 다른 문자열은 인자로 받을 수 없으니 타입 안정성이 우수하다.
다음으로 ItemStatusType 타입이 다루는 값이 무엇인지 명확해 응집력이 뛰어나다. 고로 말하고자 하는 바도 명확하다.
하지만.. 아까의 ProgrammingLanguage로 다시 돌아가보자.
ProgrammingLanguage[200]; // undefined
역방향으로 접근이 가능했다. 근데 200의 값이 있는가? 없다. 이는 undefined다. 당연히 할당된 값을 넘어서는 범위로 역방향으로 접근했을 때 참조할 수 없어 막아야하지만 타입스크립트는 막지 않는다.
const enum ProgrammmingLanguage { Java, // .. } // A const enum member can only be accessed using a string literal. ProgrammingLanguage[200]; ProgrammingLanguage["Java"]; // OK
그럼 const enum 쓰면 된다. 그럼 이렇게 문자열 리터럴로만 접근이 가능해진다.
타입 조합
교차 타입 (Intersection)
교차 타입은 여러 타입을 조합해 하나의 단일 타입을 생성하는 것으로 기존에 존재하는 타입들을 합쳐 해당 타입의 모든 멤버를 가지는 새로운 타입을 생성하는 것이다.
타입 C가 타입 A와 B의 교차 타입이라면, 타입 C는 타입 A의 멤버와 타입 B의 멤버 모두를 포함하는 것이다.
type Developer = { faceValue: number; sleepTime: number; }; type human = { name: string; age: number; }; type CodingSlave = Developer & human; /** type CodingSlave = { faceValue: number; sleepTime: number; name: string; age: number; /
이렇게 개발자는 액면가와 수면 시간을 멤버로 가진다. 그리고 인간은 이름과 나이를 멤버로 가진다.
그럼 교차 타입을 통해 생성된 코딩 노예는 이 멤버를 모두 포함하게 된다.
유니온 타입 (Union)
유니온 타입은 위에서 봤던 교차 타입과는 조금 다르게 A 또는 B 중 하나가 될 수 있는 타입을 말한다. A | B 이렇게 표현한다.
type Successed = { status: 200; response: { body: string; }; }; type ErrorState = { status: 400; message: string; }; type FetchedState = Successed | ErrorState;
위와 같이 FetchedState는 Successed와 ErrorState 둘 중 하나가 될 수 있음을 의미한다.
하지만 아래를 보자.
function beforeFetcher(state: FetchedState): void { // 공통된 타입 속성이면서 다른 값으로 확실이 구분이되면 각 요소에 접근이 가능해진다.. if (state.status === 200) { console.log("Request Success", state.response); } else { console.log("Request Failed", state.message); } }
위와 같이 공통된 속성인 status를 통해 확실하게 타입을 검사하지 않는다면 각 멤버 속성에 접근할 수 없다.
인덱스 시그니처 (Index Signature)
인덱스 시그니처는 특정 타입의 속성 이름은 알 수 없지만 속성값의 타입을 알고 있을 때 사용한다.
[Key: K]: T 일 때, 해당 타입의 속성의 key는 K 타입이어야 하고, 값에 해당하는 타입은 모두 T 타입을 가져야 한다.
interface IndexSignatureEx { [key: string]: number | boolean; age: number; sleepTime: number; worktime: number; isValid: boolean; name: string; // error }
인덱스드 엑세스 타입 (Indexed Access Types)
다른 타입의 특정 속성이 가지는 타입을 조회하기 위해 사용한다.
type Example = { a: number; b: string; c: boolean; }; type IndexedAccess = Example["a"]; // number type IndexedAccess2 = Example["a" | "b"]; // number | string type IndexedAccess3 = Example[keyof Example]; // number | string | boolean
배열의 요소 타입을 조회하는 것도 가능하다.
const PromotionList = [ { type: "product", name: "chicken" }, { type: "product", name: "pizza" }, { type: "card", name: "cheer-up" }, ]; type ElementOf<T extends readonly any[]> = T[number]; type PromotionItemType = Partial<typeof PromotionList>; // type PromotionItemType = { type: string; name: string }
맵드 타입 (Mapped Types)
자바스크립트의 map 함수를 생각해보자. 어떤 배열 A를 기반으로 새로운 배열 B를 만들어내는 배열 메서드이다.
마찬가지로 맵드 타입도 비슷하게 특정 타입을 기반으로 새로운 타입을 생성할 때 사용한다.
type Example = { a: number; b: string; c: boolean; };
이렇게 Example 타입이 있는데, 각 속성을 옵셔널하게 사용하고 싶을 때,
type Example = { a?: number; b?: number; c?: boolean; };
이렇게 직접 만들 수 있겠지만, 만약 속성이 6개, 10개씩 된다면 여간 귀찮을 수 없다.
type Subset<Type> = { [Property in keyof Type]?: Type[Property]; };
그럼 이렇게 팩터리 함수처럼 타입을 만들어 사용할 수 있다.
우선, 제네릭을 통해 Type을 받는다. 이후 keyof 연산자를 통해 Type이 가진 키 값들을 사용해 문자열 리터럴 유니온을 생성한다.
in 연산자로 생성된 유니온을 순회하며 기존 타입의 속성을 옵셔널하게 다시 재지정한다.
위의 Example 타입을 예시로 들면 아래와 같을 것이다.
type Subset<Example> = { [Property in "a" | "b" | "c"]?: Type[Property]; };
다른 예로 readonly 속성이 부여된 타입에서 readonly 속성을 모두 제거하고 싶다면 어떻게 사용할까?
type ReadOnlyEx = { readonly a: number; readonly b: string; }; type CreateMutable<Type> = { -readonly [Property in keyof Type]: Type[Property]; }; type ResultType = CreateMutable<ReadOnlyEx>;
이렇게 기존의 속성에 - 옵션을 붙여 기존 속성을 제거하는 팩터리 타입도 생성할 수 있다.
다음 쓰임을 보자.
// const BottomSheetMap = { RECENT_CONTACTS: "RecentContactsBottonSheet", CARD_SELECT: "CardSelectBottomSheet", SORT_FILTER: "SortFilterBottomSheet", PRODUCT_SELECT: "ProductSelectBottomSheet", REPLY_CARD_SELECT: "ReplyCardSelectBottomSheet", RESEND: "ResendBottomSheet", // ... 더 많은 항목 };
각 키의 값으로 문자열을 할당했지만 실제로 resolver, args, isOpened 속성을 가지는 스토어라고 가정해보자.
그럼 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; }; // ... 더 많은 항목 };
이건 좀 아닌 것 같다.
type BOTTOM_SHEET_ID = keyof typeof BottomSheetMap; type BottomSheetMapped = { [index in BOTTOM_SHEET_ID]: { resolver?: (payload: any) => void; args?: any; isOpened: boolean; }; };
위에서 봤듯, keyof 연산자는 객체의 키 값들을 사용해 문자열 리터럴 유니온을 생성한다.
typeof BottomSheetMap은 무엇일까? object를 가리킨다. 그럼 이 object에 keyof 연산자를 사용하면 BottomSheetMap 객체의 키 값을 모두 문자열 리터럴 형태로 만들 수 있다. 이제 index로 순회하며 타입을 매핑해주면 된다.
위에서 여러 예시를 살펴봤듯, 맵드 타입을 사용하면 반복적인 타입 선언을 효과적으로 줄일 수 있다.
템플릿 리터럴 타입 (Template Literal Types)
type Stage = | "init" | "select-image" | "edit-image" | "decorate-card" | "capture-image";
자바스크립트의 템플릿 리터럴 문자열을 사용해 문자열 리터럴 타입을 선언할 수 있다.
만약 내가 위의 타입에 각 속성에 대해 stage name인 것을 한 눈에 알아보고 싶어 각 이름 뒤에 ‘-stage’를 붙이고 싶다면 어떻게 할까?
type StageName = `${Stage}-stage`;
위와 같이 템플릿 리터럴 문자열로 미리 정의한 유니온 타입 멤버 이름뒤에 ‘-stage’를 추가하면 된다.
그럼 아래와 같이 정의될 것이다.
type StageName = | "init-stage" | "select-image-stage" | "edit-image-stage" | "decorate-card-stage" | "capture-image-stage";
제네릭 (Generic)
제네릭은 함수, 타입, 클래스 등 내부적으로 사용할 타입을 미리 정해두지 않고 타입 변수를 사용해 해당 위치를 비워둔 뒤, 실제로 사용할 때 외부에서 타입 변수 자리에 타입을 지정해 사용하는 방식이다.
미리 정해두지 않고 사용할 때 타입을 지정해주기 때문에 재사용성이 향상된다.
type ExampleArrayType<T> = T[]; const array1: ExampleArrayType<string> = ["jimin", "졸려"];
<T>와 같이 꺽쇠괄호 안에 정의되며, 사용할 때는 함수에 매개변수를 넣는 것과 유사하게 타입을 넣는다.
function exampleFunc<T>(arg: T): T[] { return new Array(3).fill(arg); } exampleFunc("hello");
위와 같이 함수를 정의할 때도 사용할 수 있다. exampleFunc를 호출할 때 제네릭을 직접 명시해주진 않았지만, 이 경우에는 타입스크립트가 알아서 추론해준다.
type ObjectType<T> = { // doSomething... }; type EntitySchema<T> = { // doSomething... }; type Repository<T> = { // doSomething... path: Array<T>[]; }; function ReadOnlyRepository<T>( target: ObjectType<T> | EntitySchema<T> | string ): Repository<T> { return { path: [] }; }
위와 같이 제네릭을 사용하면 매개변수, 반환 값 등 다양한 곳에 타입을 지정해줄 수 있다.
하지만 주의할 점이 있다.
제네릭은 미리 정해두지 않고 사용할 때 타입을 지정해주는 것이다. 그럼 arg는 어떤 타입도 될 수 있는 것이다.
그럼 만약 length라는 속성이 없는 타입이 매개변수로 주어질 수 있으니 아래의 경우엔 에러를 표시할 것이다.
function exampleFunc<T>(arg: T): T[] { return Array(3).fill(arg.length); }
이 때는 아래와 같이 length 속성을 가진 타입만 받도록 제약을 걸어 해결할 수 있다.
interface TypeWithLength { length: number; } function exampleFunc<T extends TypeWithLength>(arg: T): T[] { return Array(3).fill(arg.length); }
다른 예시를 보자.
type ErrorCodeType = {}; type ErrorRecord<Key extends string> = Exclude<Key, ErrorCodeType> extends never ? Partial<Record<Key, boolean>> : never;
위와 같이 ErrorRecord의 제네릭으로 받는 Key를 string 타입으로 제약하려면 특정 타입을 상속(extends)하도록 해야한다.
이처럼 타입 매개변수가 특정 타입으로 묶였을 때 키를 바운드 타입 매개변수라고 부르며, string을 키의 상한 한꼐라고 한다.
특정 타입뿐만 아니라, 상황에 따라 인터페이스나 클래스도 사용이 가능하며, 유니온 타입을 상속해 선언도 가능하다.

타입스크립트 - 기본 타입 정리하기
