3장 고급 타입

3.1 타입스크립트만의 독자적 타입 시스템

타입스크립트의 any타입의 경우 typeof나 Object.prototype.toString.call(...)을 사용해도 any라는 문자열을 반환하지 않음 -> any는 TS에만 존재하는 독자적 타입 시스템이니까

앞으로 소개될 모든 타입 시스템은 타입스크립트에만 존재하는 키워드지만, 그 개념은 자바스크립트에서 기인한 것이라는 것을 인지하자

타입스크립트의 타입 계층 구조

1. any

any타입은 JS에 존재하는 모든 값을 오류 없이 받을 수 있음 (=== 타입을 명시하지 않은 것과 동일한 효과)
누구나 알겠지만 any 타입을 변수에 할당하는 것은 지양해야할 패턴이자 any를 회피하는 것은 좋은 습관

하지만 any를 어쩔 수 없이 사용해야 하는 대표적인 3가지 예시가 있음

개발 단계에서 임시로 값을 지정해야할 때

매우 복잡한 구성요소로 이루어진 개발 과정에서 추후 값이 변경되거나 타입이 확정되지 않았을 경우
-> 하지만 any로 해놓고 다시 바꿔놓는 과정이 누락된다면 문제가 발생할 수도 있으니 주의

어떤 값을 받아올지 또는 넘겨줄지 정할 수 없을 때

API 요청 및 응답처리, 콜백 함수 전달, 외부 라이브러리 등을 사용할 땐 어떤 인자를 주고 받을지 확정하기 어렵디 때문
-> 다양한 액션 함수를 전달할 땐 any를 사용

값을 예측할 수 없을 때 암묵적으로 사용

브라우저의 Fetch API의 경우 일부 메서드는 요청 이후에 응답을 특정 포맷으로 파싱하기 때문에 반환 타입이 any로 매핑되어 있음

하지만 이러한 특수한 케이스가 있더라도 그래도 안 쓰는 게 제일 좋음!
실제 런타임에서 심각한 오류가 발생할 수 있기 때문이고 또한,
도구의 도움을 받을 수 없는 상태에서 온전히 개발자 스스로 책임을 져야하기 때문

2. unknown

unknown도 any와 마찬가지로 모든 타입의 값이 할당될 수 있지만,
any를 제외한 다른 타입으로 선언된 변수는 unknown 타입 값 할당 불가 (unknown이 들어갈 건 any만 됨)

let unknownValue: unknown;

unknownValue = 100; // any 타입과 유사하게 숫자이든
unknownValue = "hello mobi"; // 문자열이든
unknownValue = () =>  console.log("this is any type")
// 함수이든 상관없이 할당 가능하지만

let someValue1: any = unknownValue; // (o) any 타입으로 선언된 변수를 제외한 다른 변수는 모두 할당이 불가
let someValue2: number = unknownValue; // (x)
let someValue3: string = unknownValue; // (x)

-> 이름 그 자체처럼 무엇이 할당될지 아직 모르는 상태기 때문

그럼 any와 비슷한데 왜 unknown 타입이 추가 됐을까?

any보다는 unknown 타입으로 대체하는 방법을 권장하기 위해!
unknown 타입 변수에 할당할 때는 컴파일러가 아무런 경고를 주지 않지만 실행하면 에러가 발생

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

unknown 타입으로 할당된 변수는 어떤 값이든 올 수 있음을 의미하는 동시에 개발자에게 엄격한 타입 검사를 강제한다는 것

any 타입은 어떤 값이든 허용 되지만 모두들 any로 회피하려고 하기 때문에 unknown은 이러한 상황을 보완하기 위해 등장한 타입, 타입 검사를 강제하고 타입이 식별된 후 사용할 수 있기 때문에 any보다는 안전

3. void

TS에서는 void 타입이 사용되는데 이것은 undefined가 아님
TS에서 함수가 어떤 값을 반환하지 않는 경우가 void로 지정하는 것

물론 함수가 아닌 변수에도 할당할 수 있지만 undefined나 null값만 할당할 수 있다고 함
(tsconfig에서 설정한다면 null값을 할당할 수 없다고 함)

근데 그럴 바엔 직접 그냥 undefined나 null 기입하자!
어차피 함수 내부에 별도 반환문이 없으면 TS 컴파일러가 알아서 함수 타입을 void 자동추론

4. never

함수의 끝에 절대 도달하지 않음 (결코 return 하지 않음)

never도 함수와 관련하여 많이 사용 됨 -> never 타입은 값을 반환할 수 없는 타입을 말함
여기서 값을 반환하지 않는 것과 반환할 수 없는 것을 명확하게 구분해야함

구분의 2가지 예시

에러를 던지는 경우

JS에서는 런타임에 의도적으로 에러를 발생시키고 캐치할 수 있음 (throw) 이는 값을 반환하는 것으로 간주함
따라서 함수가 실행 마지막에 에러를 던지는 경우 반환 타입은 never임

무한 루프

함수가 무한루프를 실행할 때 종료되지 않기 때문에 never를 사용

nerver 타입은 모든 타입의 하위호환 -> never자신을 제외한 어떤 타입도 never 타입에 할당될 수 없음 (심지어 any도!)

TS에서 조건부 타입을 결정할 때 특정 조건을 만족하지 않는 경우에 엄격한 타입 검사 목적으로 never타입을 명시적으로 사용

void와 never의 차이

5. Array

배열 타입을 가리키는 Array 키워드는 JS에서 Object.prototype.toString.call(...)로 확인 가능 (typeof의 경우 객체의 타입을 object 타입으로 알려주지만, Obejct.prototype.toString.call(...) 함수는 객체의 인스턴스까지 알려주기 때문)

const arr = [];
console.log(Object.prototype.toString.call(arr)); // [object Array]

어? 근데 JS에도 확인 가능한자료형인데 왜 TS가 있음?

  • JS에서 사실 배열이 아닌 객체니까, 배열을 단독으로 배열이란 자료형에 국한하지 않음
  • TS에서 Array를 쓸려면 좀 특수한 문법을 함께 다뤄야함

배열은 Array키워드 외에도 대괄호 ([])를 사용해서 직접 타입을 명시할 수 있음
이럴 땐 타입은 배열보다 더 좁은 튜플을 의미

JS에서의 배열은 어떤 값이든 배열의 원소로 허용되지만 이것은 TS의 정적타이핑과는 맞지 않음

TS에선 배열의 크기까지 동시에 정적으로 제한하진 않지만, 그래도 정적이니까 웬만하면 해당 타입의 원소로 관리하는 것을 강제함 이것은 다른 언어와 유사함

const array: number[] = [1, 2, 3];
const array: Array<number> = [1, 2, 3];

처럼 제네릭을 사용하더라도 개인의 선호나 팀의 컨벤션에 따라 쓰면 됨 

TS에서 배열의 길이까지는 제한할 수 없지만, 튜플은 배열의 하위타입이므로 기존 TS의 배열 기능에 길이 제한까지 추가한 타입 시스템

let tuple: [number] = [1];

tuple = [1, 2]; // 불가능
tuple = [1, 'abc']; // 불가능

let tuple: [number, string, boolean] = [1, 'abc', true]; // 여러 타입과 혼합도 가능

대표적으로 useState에서 getter와 setter를 분리하는 것도 튜플을 이용한 원리!

6. enum

enum 열거형으로 TS에서 지원하는 특수한 타입, 구조체를 만드는 타입 시스템
열거형은 각각 멤버를 가지고 있고 이것은 JS의 객체의 모양새와 닮음, 하지만 TS는 명명한 각 멤버의 값을 스스로 추론

enum ProgrammingLanguage {
  Typescript, // 0
  Javascript, // 1
  Java, // 2
  Python, // 3
  Kotlin, // 4
  Rust, // 5
  Go, // 6
}

// 각 멤버에게 접근하는 방식은 자바스크립트에서 객체의 속성에 접근하는 방식과 동일
ProgrammingLanguage.Typescript; // 0
ProgrammingLanguage.['Rust']; // 5

// 역방향 접근 또한 가능하다.
ProgrammingLanguage[2]; // 'Java'

만약
enum ProgrammingLanguage {
  Java = 300,
  Python, // 301
  Kotlin, // 302 
  Rust, // 303 
}

enum 타입을 주로 문자열 상수를 생성하는데 사용됨

ex)

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;
  }
}

타입 안정성 / 명확한 의미 전달 / 가독성이 올라감

하지만 문자가 아닌 숫자로 되어 있다면, TS가 자동으로 추론한 열거형은 안전하지 않은 결과를 낳을 수 있음 (게다가 역방향으로도 접근이 가능하기 때문에 - 값을 넘어 역으로 접근하더라도 TS가 막지 않음)

그럴 땐 const enum을 활용한다면 역방향으로의 접근을 허용하지 않음
하지만 그렇더라도 숫자 상수로 되어있다면 막지 못하기에 최대한 문자열 상수가 가장 안전

열거형의 가장 큰 문제

하지만, 열거형의 가장 큰 문제는 타입 공간과 값 공간에서 모두 사용되기 때문에 TS코드가 JS로 변환될 때 IIFE 형식으로 변환되는 것을 볼 수 있음


일부 번들러에서 트리쉐이킹 과정 중 IIFE로 변환된 값을 사용하지 않는 코드로 인식하지 못하는 경우가 발생함 따라서, 불필요한 코드의 크기가 증가하는 결과를 낳기 때문에 const enum 또는 as const assertion을 사용하여 유니온 타입으로 열거형과 동일한 효과를 얻는 방법이 있음!


3.2 타입 조합

좀 더 심화한 타입 검사를 수행하는데 필요한 지식을 살펴봄

1. 교차 타입 (Intersection)

교집합 (헷갈리지않기 - 구조적관점에서)
인터섹션을 활용하여 여러 가지 타입을 하나의 단일 타입으로 만들 수 있음
교차타입은 &를 사용하고 / 타입 별칭을 붙일 수 있음

C = A & B -> C 는 A와 B의 모든 멤버

2. 유니온 타입 (Union)

합집합
A | B -> A 또는 B, 주로 특정 변수가 가질 수 있는 타입을 전부 나열하는 용도로 사용
인터섹션과 마찬가지로 2개 이상의 타입을 이어 붙일 수 있고 타입 별칭을 통해 중복을 줄일 수도 있음 (둘 모두가 갖고 있는 애만 사용할 수 있음)

3. 인덱스 시그니처 (Index Signatures)

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

[Key: K]: T 꼴로 명시

interface IndexSign {
	[key: string]: number;
}

속성키는 모두 K 타입 / 값은 모두 T 타입을 가져야함 (위 예시는 string / number)

즉, 가장 위에서 정의한 타입기준이라고 생각하면 됨

interface IndexSignaturesEx2 {
  [key: string]: number | boolean;
  length: number;
  isValid: boolean;
  name: string; // 에러 발생! number or boolean이 아니니까
}

4. 인덱스드 엑세스 타입 (Indexed Access Types)

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

Example 타입의 a 속성이 가지는 타입을 조회하기 위한 인덱스드 엑세스 타입
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

type ExAlias = "b" | "c";
type IndexedAccess4 = Example[ExAlias]; // string | boolean

또한 배열의 요소 타입을 조회하기 위해 인덱스드 엑세스 타입을 사용하기도 함
배열 타입의 모든 요소는 전부 동일한 타입을 가지고 배열의 인덱스는 숫자 타입이기 때문에
number로 인덱싱하여 배열 요소를 typeof 연산자를 붙여 타입을 배열의 요소 타입을 가져올 수 있음

5. 맵드 타입 (Mapped Types)

다른 타입을 기반으로 한 타입을 선언할 때 사용하는 문법
인덱스 시그니처를 사용하여 반복적인 타입 선언을 줄일 수 있음

매핑할 때 readonly와 ?를 수식어로 사용 가능
특이점은 제거하여 사용도 가능함 readonly나 ?앞에 -를 붙여주면 됨

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 cExample: Subset<Example> = { a: 4, c: true };

실사용 예시로는 배민 선물하기의 '바텀시트' 컴포넌트 (아래에서 올라오는 모달)가 존재하고 각 바텀시트마다 resolver, isOpened 등 상태관리 스토어의 타입을 선언 해줘야함

이때 모든 키에 대해 스토어를 만들기보다 인덱스 시그니처 문법을 활용해 반복을 줄일 수 있음

const BottomSheetMap = {
  RECENT_CONTACTS: RecentContactBottomSheet,
  CARD_SELECT: CardSelectBottomSheet,
  SOFT_FILTER: SoftFilterBottomSheet,
  PRODUCT_SELECT: ProductSelectBottomSheet,
  REPLAY_CARD_SELECT: ReplayCardSelectBottomSheet,
  RECEND: RecendBottomSheet,
  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;
  };
  SOFT_FILTER: {
    resolver?: (payload: any) => void;
    args?: any;
    isOpened: boolean;
  };
  // ...
};

// Mapped Types를 통해 효율적으로 타입 선언을 할 수 있다.
type BottomSheetStore = {
  // 여기에서 사용되는 BOTTOM_SHEET_ID
  [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;
  };
};

6. 템플릿 리터럴 타입 (Template Literal Types)

자바스크립트의 템플릿 리터럴 문자열을 사용하여 문자열 리터럴 타입을 선언할 수 있는 문법

위 코드의 `${index}_BOTTOM_SHEET` 예시처럼
BOTTOM_SHEET가 붙어 새로운 문자열 리터럴 유니온 타입을 만들어냄 

7. 제네릭 (Generic)

C나 자바같은 정적 언어에서 다양한 타입 간에 재사용성을 높이기 위해 사용하는 문법
한마디로 일반화된 데이터 타입 (타입을 매개변수화)

사용할 타입을 미리 정해두지 않고 타입 변수를 사용하여 해당 위치를 비워 둔 다음 그 값을 사용할 때 외부에서 타입 변수 자리에 타입을 지정하여 사용

함수, 타입, 클래스 등 여러 타입에 하나하나 따로 정의하지 않아도 되어 재사용성이 크게 향상됨

any와 제네릭의 차이

any를 사용한 배열이라 하면 모든 값을 다 받지만, 제네릭을 활용한다면 배열 요소가 전부 동일한 타입을 보장받을 수 있고 배열 생성 시점에 원하는 타입으로 특정할 수 있음

제네릭 함수를 반드시 꺾쇠괄호에 명시하지 않고 생략하면 컴파일러가 인수를 보고 타입을 추론
또한, 추론이 불가하다면 명시도 가능

제네릭은 일반화된 데이터 타입을 의미하기 때문에, 함수나 클래스 등의 내부에서 제네릭을 사용할 때 어떤 타입이든 될 수 있다는 개념을 항상 알고 있어야함
특정한 타입에서만 존재하는 멤버를 참조하려고 하면 안 됨 (ex. 배열의 length 속성)

그렇기 떄문에 'length 속성을 가진 타입을 받을게'라는 제약을 걸어줌으로 length 속성을 사용할 수 있게 함

interface TypeWithLength {
  length: number;
}

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

제네릭 사용시 주의점

파일 확장자가 tsx일 때 화살표 함수에 제네릭을 사용하면 에러가 발생함
tsx는 TS + JSX 이므로 제네릭의 꺾쇠괄호와 태그의 꺾쇠괄호를 혼동하여 생기는 문제

이럴 떈 extends 키워드를 사용하여 컴파일러에게 알려주면되고 그래서 보통 제네릭 활용시에는 function 키워드로 선언을 많이들 함

유의사항

extends {} 를 사용했을 경우 원시 타입을 포함한(string, number 등) 거의 모든 값을 제네릭으로 받을 수 있으나, null과 undefined 는 제네릭으로 사용할 수 없다.
타입스크립트의 모든 타입을 포함하는 타입인 unknown 을 사용하면 (arg: T): T 와 동일하게 사용할 수 있다.
출처: TypeScript tsx 화살표 함수에 제네릭 사용하기

+) 타입 매개변수를 두 개 이상 사용할 때는 컴파일러가 <>를 제네릭으로 정상적으로 인식한다. 타입 매개변수가 하나밖에 필요없을 때에도 뒤에 , 를 붙여 주면 컴파일러가 정상적으로 제네릭을 인식한다.


3.3 제네릭 사용법

1. 함수의 제네릭

어떤 함수의 매개변수나 반환 값에 다양한 타입을 넣고 싶을 때 제네릭 사용 가능

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

2. 호출 시그니처의 제네릭

(이 부분 다시 읽고 보기)

호출 시그니처(타입 시그니처)는 타입 스크립트의 함수 타입 문법으로 함수의 매개변수와 반환 타입을 미리 선언하는 것을 말함

제네릭 위치에 따라 타입의 범위와 제네릭 타입을 언제 구체 타입으로 한정할지 결정할 수 있음

interface useSelectPaginationProps<T> {
  categoryAtom: RecoilState<number>;
  fileAtom: RecoilState<string[]>;
  sortAtom: RecoilState<SortType>;
  fetcherFunc: (props: CommonListRequest) => Promise<DefaultResponse<ContentListResponse<T>>>;
}

3. 제네릭 클래스

외부에서 입력된 타입을 클래스 내부에 적용할 수 있는 클래스

클래스 이름 뒤에 타입 매개변수 (< T >)를 선언한 후 외부에서 타입을 받아들여 클래스 내부에서 사용될 제네릭 타입으로 결정

제네릭 클래스를 활용하면 클래스 전체에 걸쳐 타입 매개변수가 적용되고, 특정 메서드만을 대상으로 제네릭을 적용하려면 해당 메서드를 제네릭으로 선언하면 됨

4. 제한된 제네릭

타입 매개변수에 대한 제약 조건을 설정하는 기능

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

상속을 활용해 string 타입으로 제한
이렇게 타입 매개변수가 특정 타입으로 묶였을 때 (bind) 키를 바운드 타입 매개변수라고 부름

인터페이스나 클래스도 사용할 수 있고 유니온 타입을 상속해서 선언할 수 있음

5. 확장된 제네릭

제네릭은 여러 타입 상속도 가능하고 매개변수를 여러 개 둘 수도 있음

<Key extends string> -> <Key extends string | number> 
  
이런 식으로 유연성을 잃지 않게 유니온을 활용

6. 제네릭 예시

제네릭은 다양한 타입을 받게함으로 코드를 효율적으로 재사용할 수 있는 것이 장점
하지만 제네릭이 가장 많이 활용될 때는 바로 API 응답 값의 타입을 지정할 때

// 제네릭 타입 Data로 선언
export interface MobileApiResponse<Data> {
   data: Data;
   statusCode: string;
   statusMessage: string;
}
 
// 활용
export const fetchOrderInfo = (): Promise<MobileApiResponse<Order>> => {
   const orderUrl = "http: ~~~"; // url 주소

   return request({
     method: "GET",
     url: orderUrl,
   });
};

이렇듯 제네릭을 필요한 곳에 활용하면 가독성을 높이고 코드를 효율적으로 작성 가능 하지만 남발한다면 당연히 독이 됨

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

// 이렇게 쓸 바엔 (어차피 이름만 봐도 뭔지 모름)
type GType<T> = T;
type RequirementType = 'USE' | 'UN_USE' | 'NON_SELECT';
// 걍 이거 자체를 쓰기
type RequirementType = 'USE' | 'UN_USE' | 'NON_SELECT'; 

내가 작성한 코드를 다른 개발자가 쉽게 이해하지 못하고 있다면, 내가 제네릭을 오남용 하는 게 아닌지 검토해야함

profile
기록, 꺼내 쓸 수 있는 즐거움

0개의 댓글