zero-wiki Help

고급 타입

타입스크립트는 자바스크립트 자료형에 명시되어있지 않은 독자적인 타입 시스템을 가지고 있습니다. 다르게 말하면 타입스크립트의 타입 시스템이 내포하고 있는 개념은 모두 자바스크립트에서 기인한 것이다.

모든 타입이 가능한 타입

let state : any; state = { value: 0 }; // 객체를 할당해도 state = 100; // 숫자를 할당해도 state = "hello world"; // 문자열을 할당해도 state.foo.bar = () => console.log("this is any type"); // 심지어 중첩 구조로 함수를 할당해도

하지만 호환성 문제를 제기할 수 있습니다. 이는 타입스크립트로서 달성하고자 하는 정적 타이핑을 무색하게 만들 수 있습니다.

타입스크립트는 정적 타이핑이 특징인데 any 타입을 쓸 거면 자바스크립트의 동적 타이핑에서 달라지는 점이 없습니다.

따라서 이는 지양해야 하며 any 타입을 회피하는 것은 좋은 습관으로 간주됩니다.

any 타입을 명시해줘야하는 3가지 경우

  1. 개발 단계에서 임시로 값을 지정해야 할 때 매우 복잡한 구성 요소로 이루어진 개발 과정 속에서는 추후 값이 변경이 되거나 계속해서 값이 변경되는 경우가 있습니다. 아직 세부 항목에 대한 타입이 정해져 있지 않거나 확정 되어져 있지 않을 때 사용하면 경고없이 타입을 세세하게 명시하는 데 소요되는 시간을 절약할 수 있습니다.

  2. 어떤 값을 받아올지 또는 넘겨줄지 정할 수 없을 때 타입을 사용하면 정적 타이핑의 효과를 얻을 수 있습니다. 하지만 API 요청 및 응답 처리, 콜백 함수 전달, 타입이 잘 정제되지 않아 파악이 힘든 외부 라이브러리 등을 사용할 때는 어떤 인자를 주고받을지 특정하기 어렵다. 이처럼 주고받을 값이 명확하지 않을 때 열린 타입 (any 타입)을 사용할 수 있습니다.

    type FeedbackModalParams = { show: boolean; content: string; cancelButtonText?: string; confirmButtonText?: string; beforeOnClose: () => void; action?: any; }
  3. 값을 예측할 수 없을 때 외부 라이브러리나 앱 API 요청에 따라 다양한 값을 반환하는 API가 존재할 수 있습니다. 대표적으로 브라우저의 fetch api가 있습니다.

    async function load(){ const response = await fetch("<https://api.com>"); const data = await response.json(); // response.json()의 리턴 타입은 promise<any>로 정의되어 있습니다. return data; }

이렇게 예외적으로 any를 사용하는 상황이 있음에도 any는 지양하는 것이 좋다.

axios 라이브러리를 사용할 때는 다음과 같이 promise 형태의 타입을 줄 수 있습니다.

async function load(){ try { const response = await axios.get<TodoResponse>("topdragon.co.kr"); return response.data; } catch(err){ if(err instanceof Error){ throw new Error(err.message); } } }

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 someValue: any = unknownValue; // (O) any 타입으로 선언된 변수를 제외한 다른 변수는 모두 할당이 불가능 let someValue: number = unknownValue; // (X) let someValue: string = unknownValue; // (X)

unknown 타입은 any타입과 똑같은 것 같은데 ts 3.0 버전에서 출시가 되었다. 왜 출시가 된 것일까?

예시를 다시보자.

// 할당하는 시점에서는 에러가 발생하지 않는다. const unknownFunciton = () => console.log("this is any type"); // 실행시에 에러가 발생한다. // Error: Object is of type 'unknown'.ts (2571) unknownFunciton();

이는 함수뿐만 아니라 객체의 속성 접근, 클래스 생성자 호출을 통한 인스턴스 생성 등 객체 내부에 접근하는 모든 시도에서 에러가 발생한다.

할당 시점에는 문제가 없는데 왜 호출 시 문제가 생기는지 의아하다.

unknown 타입으로 선언된 변수는 값을 가져오거나 내부 속성에 접근할 수 없다. 이는 unknown 타입으로 할당된 변수는 어떤 값이든 올 수 있음을 얘기함과 동시에 개발자에게 엄격한 타입 검사를 강제하는 의도를 담고 있습니다.

any 타입은 예상치 못한 버그를 마주할 수 있지만 unknown 타입에서는 개발자에게 검사를 강제하여 타입을 식별한 후에 사용할 수 있기 때문에 any 타입보다 안전한 것을 알 수 있습니다.

따라서 데이터 구조를 파악하기 힘들 때 any 타입 대신 unknown 타입으로 대체해서 사용하는 방법이 권장된다.

unknown은 어떨 때 사용할 수 있을까요? 배달 이팀 : 강제 타입 캐스팅을 통해 타입을 전환할 때 사용합니다.

const env = process.env as unknown as ProcessEnv

왕팀 : 예상할 수 없는 데이터라면 unknown을 사용합니다. 타입스크립트의 4.4 버전에서부터 try-catch의 에러의 타입이 any에서 unknown으로 변경이 되어 에러 핸들링을 할 때도 unknown을 사용합니다. 하지만 as unknown as Type 과 같이 강제 타입 캐스팅을 할 때에는 any와 다를 바가 없어서 지양해야 합니다.

void 타입

자바스크립트에서 함수에서 아무런 값도 리턴하지 않는다면 기본적으로 undefined가 반환이 된다. 하지만 타입스크립트에서는 void 타입이 사용이 되는데 이것은 undefined가 아니다.

타입스크립트에서 함수가 어떤 값을 반환하지 않는 경우에는 void를 지정하여 사용한다고 생각하면된다.

앞 예시와 같은 사례에서 함수 반환 타입을 void로 지정할 수 있습니다. void 타입은 주로 함수 반환 타입으로 사용되지만 사실 함수에 국한된 타입은 아니다. 변수에도 할당이 가능하다. 그렇지만 함수가 아닌 값에 대해서는 무의미하다.

void 타입으로 지정된 함수에 대해서는 undefind 또는 null 값만 할당 할 수 있습니다. 하지만 이또한 명시적인 의미를 부여한다는 관점에서 보았을 떄는 undefined나 null 타입 키워드를 직접 사용해서 타입을 지정하는 것이 더 바람직하다.

void 타입을 명시해주는 일은 적다 그 이유는 타입스크립트에서 함수의 반환 값을 주지 않을 경우 void로 타입 추론이 가능하기 때문이다 :)

never 타입

never 타입은 값을 반환하지 않는 타입이 아니라 값을 반환할 수 없는 타입을 말한다. 크게 2가지로 분류가 된다.

1. 에러를 던지는 경우 자바스크립트는 런타임에 의도적으로 에러를 발생시키고 캐치할 수 있습니다. throw 키워드를 사용하면 에러를 발생시킬 수 있는데, 이는 값을 반환하는 것으로 간주하지 않는다. 따라서 함수 마지막에 에러를 던지는 작업이 있다면 해당 함수의 반환 타입은 never이다.

2. 무한히 함수가 실행되는 경우 함수 내에서 무한 루프를 실행하는 경우가 있을 수 있습니다. 무한 루프는 결국 함수가 종료되지 않음을 의미하기 때문에 값을 반환하지 못한다.

function checkStatus(): never { while (true){ // ... } }

never 타입은 모든 타입의 하위 타입이다. 즉, never 자신을 제외한 어떤 타입도 never 타입에 할당될 수 없다는 것을 의미한다. 심지어 any 타입도 할당이 불가능하다.

따라서 타입스크립트에서는 조건부 타입을 결정할 때 특정 조건을 만족하지 않는 경우에 엄격한 타입 검사 목적으로 never 타입을 명시적으로 사용하기도 한다.

Array 타입

만약 대괄호[]를 사용하여 명시를 해줄 경우 좁은 범위의 튜플 형태로 선언이 가능합니다.

const array: [number, string, () => void] = [1, "string", fn]; // 자바스크립트에서는 배열에 숫자, 문자열, 함수 등 다양한 값을 삽입할 수 있습니다.

자바스크립트의 배열에서는 배열안에 다른 타입의 값이 들어갈 수 있습니다. 하지만 이는 타입스크립트의 정적인 특징과는 다릅니다 때문에 다른 언어들과 마찬가지로 타입을 명시를 해줄 수 있습니다.

Java

String[] array = { "string1", "string2", "string3" }; // String 타입의 배열로 선언된 array에 int, float 같은 다른 자료형의 원소는 허용되지 않는다.

C++

int array[3] = { 10, 20, 30 }; // int 타입의 array는 다른 타입의 원소를 허용하지 않는다.

타입스크립트에서도 Array 배열의 타입을 선언할 수 있는 방법은 총 두 가지로 구성이 되어있습니다.

const number1: number[] = [1, 2, 3]; const number2: Array<number> = [1, 2, 3]; const string1: string[] = ["name", "age", "gender"]; // 여러 타입을 받는 경우 const array1: Array<number | string> = [1, "string"]; const arrat2: Array[] | string[] = [1, "string"]; // 후자의 방식은 아래와 같이 선언할 수 있습니다. const array3: (number | string)[] = [1, "string"];

다른 정적인 언어의 경우 배열의 크기도 제한 할 수 있지만 타입스크립트에서의 배열에서는 튜플을 사용하지 않는 이상 배열의 크기 제한을 하지 못한다.

const user_name : [string, string, string] = ["3", "3", "4"];

타입스크립트에서의 배열과 튜플은 자바스크립트와 달리 제한적으로 쓰인다. 배열은 사전에 허용되지 않은 타입이 섞이는 것을 방지하여 타입 안정성을 제공하며, 튜플은 타입과 원소 개수까지 보장한다.

튜플의 경우 컨벤션을 잘 지키고 각 배열 원소의 명확한 의미와 쓰임을 보장할 때 더욱 안전하게 사용할 수 있는 타입입니다. 잘 사용하는 예시로 useState API를 들 수 있습니다.

또한 구조 분해 할당을 통해 사용자가 이름을 명시 할 수도 있습니다.

import { useState } from "react"; const [value, setValue] = useState(false); const [username, setUserName] = useState("");

구조 분해 할당은 배열뿐만 아니라 객체에 대해서도 적용 할 수 있습니다. 하지만 사전에 선언된 속성 이름을 통해 값을 가져오므로 튜플보다 유연성은 떨어질 수 있습니다.

튜플과 배열의 성질을 혼합해서 사용할 수 도 있습니다.

const httpStatusFromPaths: [number, string, ...string[]] = [ 400, "Bad Request", "/users/:id", "/users/:userId", "/users/:uuid", ] // 첫번째 자리는 숫자로 두번째 자리는 문자열로 나머지는 문자열로 길이에 상관없이 받을 수 있습니다.

옵셔널 프로퍼티를 통해 해당 속성을 선언할 수도 있습니다.

const optionalTuple1: [number, number, number?] = [1, 2]; const optionalTuple2: [number, number, number?] = [1, 2, 3]; // 3번째 인덱스에 해당하는 숫자형 원소는 있어도 되고 없어도 됨을 의미한다.

enum

enum 타입은 주로 문자열 상수를 생성하는데 사용한다 이를 이용하여 응집력있는 집합 구조체를 만들 수 있습니다.

enum ItemStatusType { DELIVERY_HOLD = "DELIVEY_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.DELIVERD: default: return true; } }

열거형 타입 enum을 사용할 때의 얻을 수 있는 장점

  1. 타입 안정성 : 명시되지 않은 다른 문자열을 전달받을 수 없습니다.

  2. 명확한 의미 전달과 높은 응집력: 특정 타입이 다루는 값이 무엇인지 명확하고 상태에 대한 값을 모아놓은 것으로 응집력이 뛰어나다.

  3. 가독성: 응집도가 높기 떄문에 말하고자 하는 바가 명확하다. 따라서 열거형 멤버를 통해 어떤 상태를 나타내는지 쉽게 알 수 있습니다.

이처럼 열거형 타입은 관련이 높은 멤버들을 모아 문자열 상수로 사용하고자 할 때 유용하게 사용할 수 있습니다.

다만 주의해야 할 점이 있습니다. 먼저 숫자로만 이루어져 있을 경우 자동으로 추론한 열거형은 안전하지 않은 결과를 낳을 수 있습니다. 할당된 값을 벗어나더라도 에러는 발생하지 않습니다.

하지만 const enum으로 선언을 할 경우 값 이외의 접근을 허용하지 않습니다. 하지만 숫자로 선언한 경우에는 이를 방지하지 못합니다.

따라서 문자열 상수 방식으로 선언하여 미리 선언하지 않은 멤버로의 접근을 방지하는 것이 안전하며 도움이 됩니다.

const enum NUMBER { ONE = 1, TWO = 2, } const myNumber: NUMBER = 100; // NUMBER enum에서 100을 관리하고 있지만 이는 에러를 발생시키지 않는다. const enum STRING_NUMBER { ONE = "ONE", TWO = "TWO", } const myStringNumber: STRING_NUMBER = "THREE"; // Error

하지만 이것보다 가장 큰 문제가 있는데 바로 열거형은 타입 공간과 값 공간 모두 사용이 되기 때문에 즉시 실행 함수(IIFE) 형식으로 실행된다는 것이다.

때문에 일부 번들러에서 트레쉐이킹 과정 중 즉시 실행 함수로 변환된 값을 사용하지 않는 코드로 인식하지 못하는 경우 가 발생하기도 한다. 이로인해 불필요한 코드가 늘어 날 수 있습니다.

하지만 앞서 언급했던 const enum 또는 as const assertion을 사용하여 열거형과 동일한 효과를 얻는 방법이 있습니다.

타입 조합

교차타입

교차 타입을 사용할 경우 여러 타입을 하나의 타입으로 만들 수 있습니다.

type ProductItem = { id: number name: string type: string price: number imageUrl: string quantity: number } type ProductItemWithDiscount = ProductItem & { discountAmount: number }

유니온 타입

교차 타입은 (A & B)이 타입 A와 타입 B를 모두 만족하는 경우라면 유니온 타입은 A 또는 B 타입이 될 수 있는 타입을 말하며 A | B와 같이 표기한다.

type CardItem = { id: number name: string type: string imageUrl: string } type PromotionEventItem = ProductItem | CardItem const printPromotionItem = (item: PromotionEventItem) => { console.log(item.name) // 0 console.log(item.quantity) // Property 'quantity' does not exist on type 'PromotionEventItem'. // Property 'quantity' does not exist on type 'CardItem'.ts(2339) }

이와 같이 선언한 타입의 경우 공통된 인자가 아닌 값(위에서의 quantity)을 참조하려고 들다보면 컴파일 에러가 생긴다. 이는 유니온 타입으로 선언이 되었지만 공통된 인자가 아니기 때문에 에러가 나는 것이다. 물론 자동완성도 되지 않는다.

만약 여러 줄에 걸쳐서 유니온 타입을 사용하고 싶다면 다음과 같이 사용할 수 있습니다.

type PromotionEventItem = & ProductItem & CardItem type PromotionEventItem = | ProductItem | CardItem

인덱스 시그니처

특정 타입의 속성이름은 알 수 없지만 속성 값의 타입을 알고 있을 때 사용하는 문법이다.

interface IndexSignatureEx { [key: string]: number }

인터페이스 내부에 [key: K] : T 꼴로 타입을 명시하여 사용한다. 이는 해당 타입의 속성 키는 모두 K 타입이어야 하고 모두 T 타입을 가지고 있음을 의미한다.

interface IndexSignatureEx { [key: string]: number | boolean name: string // Property 'name' of type 'string' is not assignable to 'string' index type 'number'.ts(2411) }

만약 [key: K] : T 에서 정해진 T 타입의 값을 넣지 않는다면 에러가 발생한다.

인덱스드 엑세스 타입

이는 다른 타입의 특정 속성을 가지는 타입을 조회하기 위해 사용된다.

type Example = { a: number b: string c: boolean } type IndexAccess1 = Example["a"] // number type IndexAccess2 = Example["a" | "b"] // number | string type IndexAccess3 = Example[keyof Example] // number | string | boolean type ExAlias = "b" | "c" type IndexedAccessess4 = Example[ExAlias] // string | boolean

위 예시 코드의 IndexedAccess는 Example 타입의 속성들을 조회하기 위한 인덱스드 엑세스 타입이다. 인덱스에 사용되는 타입 또한 그 자체로 타입이기 때문에 유니온 타입, keyof, 타입 별칭 등의 표현을 사용할 수 있습니다.

배열의 요소 타입을 조회하기 위한 인덱스드 엑세스 타입 배열의 요소 타입을 조회하기 위해 사용하는 경우도 있습니다. 아래 예시를 보면. 배열 타입의 모든 요소는 전부 동일한 타입을 가지며 배열의 인덱스는 숫자 타입이다. 따라서 num-ber로 인덱싱하여 배열 요소를 얻은 다음에 typeof 연산자를 붙여 배열 요소의 타입을 가져올 수 입니다.

const PromotionList = [ { type: "product", name: "chicken" }, { type: "product", name: "pizza" }, { type: "card", name: "cheer-up" }, ] type ElementOf<T> = T extends unknown[] ? T[number] : never // type PromotionItemType = { type: string; name: string; } type PromotionItemType = ElementOf<typeof PromotionList>

맵드 타입

map 이라고 하면 자바스크립트에서 map 메서드를 떠올리기 쉽습니다. 이는 배열 A를 기반으로 배열 B를 만들어내는 Array 클래스의 메소드입니다.

이를 이용하면 반복적인 타입 선언을 효과적으로 줄일 수 있습니다.

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: "string" } const cExample: Subset<Example> = { c: true }

의문이 들었습니다.Partial<T>를 사용해도 모든 키 값을 선택적으로 만들 수 있는데 무엇이 다르다는 걸까요?

const aExample: Partial<Example> = { a: 3 } const bExample: Subset<Example> = { b: "string" }

두 개의 타입은 모두 주어진 타입의 모든 속성을 선택적으로 만들어줍니다. 따라서 Subset과 Partial의 차이는 없으며, 두 가지 표현 방식 중 하나를 선택하여 사용하면 됩니다. 예를 들어, 위의 코드 예시에서 aExamplebExample은 모두 Example 타입의 속성을 선택적으로 만들었습니다.

다시 한 번 죄송합니다. 혼동을 드려 죄송합니다. Subset과 Partial은 동일한 동작을 수행합니다.

그럼 실제로 Partial<T> 코드를 뜯어보기로 하였습니다.

type Partial<T> = { [P in keyof T]?: T[P]; };

둘은 코드가 똑같은 것을 볼 수 있었다.

맵드 타입은 이 뿐만이 아니라 기존에 작성된 코드를 변경을 해줄 수 도 있다.

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 Concreate<Type> = { [Property in keyof Type]-?: Type[Property] } type ResultType = Concreate<OptionalEx> // { a: number; b: string; c: boolean }

배달의 민족 선물하기 서비스에서 '바텀시트'라는 컴포넌트가 존재한다. 해당 바텀시트 컴포넌트에서는 선물하기 서비스의 최근 연락처 목록, 카드 선택, 상품 선택 등 여러 지면에서 사용하고 있는 재사용성이 있는 컴포넌트이다.

때문에 바텀시트마다 각각 resorver, isOpened 등의 상태를 관리하는 스토어가 필요한데 이 스토어의 타입(BottomSheetStore)를 선언해줘야 한다.

이때 존재하는 모든 키에 대해 하나씩 만들어 줄 수 있지만 불필요한 반복이 발생하게 된다. 이때 인덱스 시그니처 문법을 사용하여 BottomSheetMap을 기반으로 각 키에 해당하는 스토어를 선언할 수 있다.

const BottomSheetMap = { RECENT_CONTACTS: RecentContactsBottomSheet, CARD_SELECT: CardSelectBottomSheet, 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 } } type BottomSheetStore = { [index in BOTTOM_SHEET_ID]: { resolver?: (payload: any) => void args?: any isOpened: boolean } }

맵드 타입에서는 as 키워드를 사용하여 키를 재지정 할 수도 있습니다. 만약 키 이름을 공통되게 사용하고 싶다면 다음과 같이 새로운 키를 만들 수도 있습니다.

type BottomSheetStore = { [index in BOTTOM_SHEET_ID as `${index}`_BOTTOM_SHEET`]: { resolver?: (payload: any) => void args?: any isOpened: boolean } } /* type BottomSheetStore = { RECENT_CONTACTS_BOTTOM_SHEET: { resolver?: ((payload: any) => void) | undefined; args?: any; isOpened: boolean; }; CARD_SELECT_BOTTOM_SHEET: { resolver?: ((payload: any) => void) | undefined; args?: any; isOpened: boolean; }; */

탬플릿 리터럴 타입

자바스크립트의 탬플릿 리터럴 문자열을 사용하여 문자열 리터럴 타입을 선언할 수 있는 문법이다. 위처럼 각 키에다가 이름을 수정한 예시가 바로 템플릿 리터럴 타입을 활용한 것입니다.

type Stage = | "init" | "select-image" | "edit-image" | "decorate-card" | "capture-image"; type StageName = `${Stage}-stage`; // 'init-stage' | 'select-image-stage' | 'edit-image-stage'...

이처럼 탬플릿 리터럴 타입을 사용하면 새로운 문자열 유니온 타입이 만들어집니다.

제네릭

제네릭은 C나 자바 같은 정적 타입 언어에서 타입 간에 재사용성을 높이기 위해 사용하는 문법입니다.

타입스크립트 제네릭의 개념

함수, 타입, 클래스 등에서 내부적으로 사용할 타입을 미리 정해두지 않고 그 값을 사용할 때 외부에서 타입 변수 자리에 타입을 지정하여 사용하는 방식을 말합니다. 이렇게 할 경우 함수, 타입, 클래스등을 따로 정의 하지 않아도 되기 때문에 재사용성이 크게 향상이 됩니다. 타입 변수는 <T>와 같이 꺾쇠괄호내에 정의하며 함수에 매개변수를 넣는 것과 유사하게 원하는 타입을 넣어주면 된다. 타입 변수명으로는 T(Type), E(Element), K(Key), V(Value) 등 한 글자로 된 이름을 많이 사용한다.

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 속성을 제네릭을 사용하여 참조하려고 하면 당연히 에러가 나기 마련이다.

function exampleFunc2<T>(arg: T): number { return arg.length; // 에러 발생: Property 'length' does not exis on type 'T' }

이럴 때는 제네릭 꺽쇠 괄호 안에 length 속성을 가진 타입만 받는다. 라는 제약 조건을 걸 수 있다.

interface TypeWithLength { length: number; } function exampleFuc2<T extends TypeWithLength>(arg: T): number { return arg.length; }

만약 파일 확장자가 .typescript 일 경우 화살표 함수에 제네릭을 사용하면 에러가 발생한다. 꺾쇠괄호와 태그의 꺽쇠괄호를 혼동하여 문제가 발생하는 것이다. 이러한 상황을 해결하기 위해 컴파일러에게 특정 타입의 하위 타입만 올 수 있음을 알려주면 된다.

이러한 문제로 인해 제네릭을 사용할 경우 function 키워드로 선언하는 경우가 많다.

// 💩 Bad Case // JSX element 'T' has no corresponding closing tag.ts(17008) const arrowExampleFunc = <T>(arg: T):T[] => { return new Array(3).fill(arg); } // 😀 Good Case const arrowExampleFunc = <T extends {}>(arg: T):T[] => { return new Array(3).fill(arg); }

제네릭 사용법

함수에서의 제네릭

함수의 매개변수나 반환 값에 다양한 타입을 넣고 싶을 때 제네릭을 사용할 수 있습니다.

function ReadOnlyRepository<T>(target: ObjectType<T> | EntitySchema<T> | string):Repository<T> { return getConnection("ro").getRepository(target); }

호출 시그니처의 제네릭

호출 시그니처를 사용함으로써 개발자는 함수 호출 시 필요한 타입을 별도로 지정할 수 있게 됩니다. 제네릭 타입의 위치에 따라 범위와 언제 구체타입으로 한정할 지 결정할 수 있습니다.

interface useSelectpaginationProps<T>{ categoryAtom: RecoilState<number>; filterAtom: RecoilState<string[]>; sort Atom: RecoilState<SortType>; fetcherFunc: (props: CommonListRequest) => Promise<DefaultResponse<ContentResponse<T>>>; }
export type UseRequesterHookType = <RequestType = void, ResponseData = void>(baseURL ?: string | Headers, defaultHeader?: Headers) => [RequestStatus, Requester <RequestData, ResponseData>];
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 } }

제네릭 클래스

외부에서 입력된 타입을 클래스 내부에 적용할 수 있는 클래스이다. 다음과 같은 형태로 선언된다.

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 IndexDB implements ICacheStore { private _DB?: LocalDB<{ key: string; value: Promise<Record<string, unknown>>; cacheTTL: string}>; private DB(){ if (!this.DB){ this._DB = new LocalDB("localCache", {ver: 6, tables: [{ name: TABLE_NAME, keyPath: "key" }]}); } return this._DB; } // ... }

클래수 이름 뒤에 사용할 타입 매개변수인 T를 받아 위와 같이 사용이 가능하다.

제한된 제네릭

제한된 제네릭은 타입 매개변수에 타입 매개변수 대신 제약 조건을 설정하는 기능을 말한다. 타입 매개변수 <T> 타입을 제약한다면 다음과 같다.

type ErrorRecord<Key extends string> = Exclude<Key, ErrorCodeType> extends never ? Partial<Record<Key, boolean>>: never;

이처럼 제약을 걸게하려면 특정 타입을 상속 extends 해야한다. 이때 바인드 된 Key의 타입을 바운드 타입 매개변수라고 부르고 string을 키의 상한 한계라고 부릅니다.

function useSelectPagination<T extends CardListContent | CommonProductResponse>({ filterAtom, sortAtom, fetcherFunc } : useSelectPaginationProps<T>): { intersectionRef:RefObject<HTMLDivElement>; data: T[]; categoryId: number; isLoading: boolean; isEmpty: boolean; } { // ... } // 사용하는 쪽 코드 const {} = useSelectPagination<CardContent>({ categoryAtom: replyCardCategoryIdAtom, filterAtom: replyCardFilterAtom, sortAtom: replyCardSortAtom, fetcherFunc: fetchReplyCardListByThemeGroup, });

확장된 제네릭

제네릭 타입은 여러 타입을 상속받을 수 있으며 타입 매개변수를 둘 수도 있다.

<Key extends string>

하지만 이는 제네릭의 유연성을 해치는 행위이다. 따라서 유연성을 잃지 않으면 서 상속을 할 때에는 유니온 타입을 사용하면 된다.

<Key extends string | number>

유니온 타입으로 T가 여러 타입을 받게 할 수는 있지만 여러 개일 때는 처리를 하지 못한다.

이럴 때는 매개변수를 하나 더 추가하여 선언한다.

export class APIResponse<Ok, Err = string> { private readonly data: Ok | Err | null; private status: ResponseStatus; private statusCode: number | null; constructor(data: Ok | Err | null, status: ResponseStatus, statusCode: number | null){ 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.responsedata?.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 APIResponse.get<IShopResponse | null>("/v1/main/shop", config)).map((it)=> it.result) }

제네릭 예시

제네릭의 장점은 앞서 보았다싶이 다양한 타입을 받게함으로써 효율적으로 재사용할 수 있는 것이다.

그렇다면 실제 현업에서 가장 많이 제네릭을 활용할 때는 언제일까? 바로 API 응답 값의 타입을 지정할 때이다.

export interface MobileApiResponse<Data> { data: Data; statusCode: string; statusMessage?: 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, }) }

이처럼 필요한 곳에서 제네릭을 사용하게 된다면 코드의 가독성이 좋아지고 코드를 효율적으로 작성할 수 있습니다.

그렇다면 반대로 제네릭을 사용하지 않아도 되는 타입은?

제네릭을 굳이 사용하지 않아도 되는 타입

다음은 굳이 필요하지 않은데 사용한 예시이다.

type GType<T> = T; type RequirementType = "Use" | "UN_USE" | "MON_SELECT", interface Order { getRequirement(): GType<RequirementType>; }

함수의 반환 값으로만 사용 한다고 하면 다음과 같이 사용하는 것과 동일하다.

type RequirementType = "Use" | "UN_USE" | "MON_SELECT", interface Order { getRequirement(): RequirementType; }

any 사용하기

any를 사용하는 것은 타입 추론, 타입 검사의 장점이 없다 즉 자바스크립트를 사용하는 것과 똑같다 이는 지양하는 것이 좋다.

가독성을 고려하지 않은 사용

제네릭을 과하게 사용되면 가독성을 해치기 때문에 코드를 읽고 타입을 이해하기가 어려워진다. 부득이한 상황을 제외하고 복잡한 제네릭은 의미 단위로 분활해서 사용하는 것이 좋다.

type 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>
Last modified: 07 August 2024