10 분 소요

Typescript Util

Typescript 언어에서 Conditional Type은 무엇인지, 또 Infer 타입과 Type guard까지 알아본다.

Conditional Type

Conditional Type란?

삼항연산자를 이용해서 조건적으로 지정하는 타입

Type ConditionType<T, U> = T extends U ? X : Y

T가 U에 포함되면 X라는 타입을, 아니라면 Y라는 타입을 반환해주는 타입을 지정할 수 있다.

☝ extends는 부분 집합의 개념으로 이해하면 쉽다!

조건 형식

삼항 연산자의 조건문에 올 수 있는 문법은 TypeScript 4.3 기준으로 T extends U 형식밖에 없다. 아직까지 타입에서 조건을 나타낼 수 있는 문법이 extends 키워드 밖에 없다.
⇒ 참고 아티클

(🤔 이 부분이 궁금해서 찾아보는데 잘 안나오는 것 같다.)

예시

변수 생성 시 인자로 넘어오는 값에 따라서 다르게 타입을 주려면

type IdLabel = { id: number };
type NameLabel = { name: string };

🤨 아마도 내가 했다면

return을 분기처리해도 어차피 NameLabel, IdLabel 둘 중 하나이니 이렇게 처리하지 않았을까?

function createLabelMe<T extends number | string>(
  idOrName: T
): NameLabel | IdLabel {
  if (typeof idOrName === "number") {
    return { id: idOrName as number };
  }

  return { name: idOrName as string };
}

let aa = createLabelMe("typescript"); // aa: NameLabel | IdLabel

let bb = createLabelMe(2.8); // bb: NameLabel | IdLabel

let cc = createLabelMe(Math.random() ? "hello" : 42); // cc: NameLabel | IdLabel

😊 Docs에는

타입 파라미터에 따라서 return type이 정해지게 타이핑했다!
-> Conditional Type을 이용!

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript"); // a: NameLabel

let b = createLabel(2.8); // b: IdLabel

let c = createLabel(Math.random() ? "hello" : 42); // c: NameLabel | IdLabel

인자 idOrName는 number이거나 string일 수 있다.

그러나 반환 타입은 모든 타입을 허용하기 보다 string인자가 들어왔을 때는 string으로 반환하고, number가 들어왔을 때는 number 타입으로 반환하도록 하려면 Conditional Typing 하면 된다.

⇒ 반환 타입도 Conditional Typing하여 조건적으로 지정된 타입을 사용할 수 있게 된다.

좀 더 Typescript스럽게!


🙊 왜 createLabel 함수는 return을 제대로 해주지 않을까?

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

*스택 오버플로우를 참조했습니다.

이는 타입스크립트의 한계라고 할 수 있다!

제네릭 타입, 특히 Conditional Type에서 불확실한 타입은 Typescript가 컴파일 단에서 확정지을 수 없다.

타입 파라미터가 평가될 때 까지는 아무도 모르므로 return 타입을 어느 하나로 특정할 수 없다.

타입 단언으로 하려면 할 수는 있다.

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  if (typeof idOrName === "number") {
    return { id: idOrName as number } as NameOrId<T>;
  }
  return { name: idOrName as string } as NameOrId<T>;
}

Type Assertion이 좋거나 나쁘다기 보다 그냥 개인이 보기에 좋지 않다면 다른 방법도 있다.

다른 방법은 타입스크립트의 Function overloads 를 사용하는 것!

Typescript 핸드북 - overloads

// overload signatures
function len(s: string): number;
function len(arr: any[]): number;

// implementation signature
// body of the function
function len(x: any) {
  return x.length;
}

따라서 이를 사용해보면, createLabel Overloads signature에서 T를 사용해서 Conditional Typing하면 된다고 한다.

컴파일러는 Overload function에서 return 타입이 IdLabel NameLabel인 것을 알기 때문에 타입 단언이 없어도 컴파일 단 타입 에러가 나지 않는다고 합니다.
function createLabel<T extends number | string>(idOrName: T): NameOrId<T>;
function createLabel(idOrName: number | string): NameOrId<number | string> {
  if (typeof idOrName === "number") return { id: idOrName };
  return { name: idOrName };
}

Conditional Type의 활용

Pick처럼 특정 타입의 특정 속성 타입만 가져오고 싶을 때!

type MessageOf<T> = T["message"];

❌ 에러: Type '"message"' cannot be used to index type 'T'.

당연하게도, 에러가 발생한다.
⇒ T type에 message가 있는지 없는지 모르기 때문.

🤔 나름 고쳐보기

type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}

type EmailMessageContents = MessageOf<Email>;

그냥 T에 constraint(제약조건)을 두면 되는 것 아닌가!?
아싸 잘 된다. 끝! 😆

🧐 그런데 만약에 message가 없어도 타입을 특정 타입으로 뱉고 싶게 한다면?

→ 이 때 Conditional Typing으로 타입을 지정해보기!

// T는 아무거나 와도 됨. 조건부가 처리해줄 것.
type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessageContents = MessageOf<Email>; // EmailMessageContents: string

type DogMessageContents = MessageOf<Dog>; // DogMessageContents: never

T 타입은 아무거나 일단 받고(message가 없더라도),

Type 안 조건부에서 extends로 true/false 확인 후 false 인 경우 never를 반환해주면 된다.


무궁무진히 고도화할 수 있을 것 같은 Conditional Type이다.


Infer 키워드

Infer란?

There is an infer keyword that can be used within a condition in a conditional type to put the inferred type into a variable. That inferred variable can then be used within the conditional branches.

즉, Conditional Type에서 타입을 변수화해서 사용할 수 있는 키워드.

변수화한 타입(inferred variable)을 조건부 브랜치에 사용할 수 있다.

type ConditionalTypeWithInfer<T> = T extends { property: infer R } ? R : T;

⇒ infer 키워드와 지정한 네이밍 타입(R)을 사용하며, infer를 이용해서 T의 타입에 있는 타입을 가져와서 True/False 브랜치에 사용한다.

❓그냥 설명만 보면 무슨 말인지 감이 안잡힌다.

그러나 설명보다 사용하는 게 더 쉬운 infer!

예시

// ArrayType을 받아 그 Array의 요소가 무슨 타입인지 확인하여 요소의 타입을 반환해주는 조건부 타입
type ArrayElementType<T> = T extends (infer R)[] ? R : T;

type NumberItemType = ArrayElementType<number[]>;
// type of NumberItemType is `number`

type NotArrayType = ArrayElementType<{ name: string }>;
// type of NotArrayType is `{name: string}`

ArrayElementType 은 특정 타입의 배열을 받아서 그 배열의 요소 타입을 타입으로 지정해줌, 배열이 아닌 타입이라면 그냥 받은 타입 그대로 지정하기

😌 차근차근 보기

NumberItemType 은 ArrayElementType에 number[] 타입을 넣었다. 그럼 어떤 타입이 반환될까?

여기서 infer는 그래서 무슨 역할을?
→ infer 이 키워드를 사용해서 number 타입을 쏙! 빼올 수 있는 것이다!

처음에 이것을 이해하는지 왜 어려웠는지는 모르지만
한 번 이해하고 나면 너무 간단한 유틸리티 키워드라 조금 어이없는 부분이 있다. 😵‍💫

활용: 함수의 반환 타입

이 infer 키워드는 함수의 반환 타입을 타입마다 다르게 하고 싶을 때 많이 사용하게 된다!

type FunctionReturnType<T> = T extends (...args: any) => infer R ? R : T;

⇒ T로 받은 타입이 함수일 때는 infer 키워드로 R 타입(반환 타입)을 가져오고 아니라면 그냥 T로 지정한다.

function isOddNumber(num: number): boolean {
  return num % 2 === 1;
}

function getUserNameById(userId: number): string {
  // get userName from somewhere
  return "jisu-yoo";
}

function whatAboutVoidFunc(): void {
  // ...
}

😶‍🌫️ 다음의 타입은? (쉬움 주의)

type CheckOddNumberType = FunctionReturnType<typeof isOddNumber>;
type UserNameType = FunctionReturnType<typeof getUserNameById>;
type VoidType = FunctionReturnType<typeof whatAboutVoidFunc>;
  • 타입은?
    CheckOddNumberType: boolean
    UserNameType: string
    VoidType: void
    

🙄 좀 더 디벨롭

const ObjectType = { numberProperty: number };

type WhatType = FunctionReturnType<ObjectType>; // ?

ObjectType도 FunctionReturnType 타입 파라미터를 통해 넘겨줄 수 있다

⇒ 제네릭 자체는 constraint(제약조건)이 없기 때문에, T extends (...args: any) => infer R 이 조건을 확인하게 된다.

그런데 이 조건에서 T 타입은 함수여야 하는 조건이 있다.

⇒ 그러므로 False 브랜치가 타입으로 지정되게 되므로 그대로 T 타입인 ObjectType이 지정된다.

근데 어차피 함수만 return type을 확인하고 싶은데 굳이 함수 타입이 아닌 타입까지 파라미터로 받아야할까 싶은 것이다.

type FunctionReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : T;

애초에 함수 타입만 가능하도록 지정해두면 함수 타입이 아닌 타입을 넘겨주었을 때 Type error가 발생되므로 사용자가 더 편할 수 있지 않을까? 🥺

활용: Template Literal + Infer 조합

토스에서도 Template Literal을 많이 이용한다고 한다.

// 출처: 토스
type InOrOut<T> = T extends `fade${infer R}` ? R : never;

type I = InOrOut<"fadeIn">; // type I = "In"
type O = InOrOut<"fadeOut">; // type O = "Out"

활용: 재귀

// 출처: 토스
type TrimRight<T extends string> = T extends `${infer R} `
  ? TrimRight<R> // 오른쪽 띄어쓰기가 없을 때까지 Recurse
  : T;

// type T = "Trim"
type T = TrimRight<"Trim      ">;

⇒ Type에서는 if / for / while을 사용할 수 없어 재귀적으로 구현한다.

이 재귀를 이용하여 nesting된 타입, Literal Type에서 반복적인 string 없애기 등 활용도가 매우 높다!

그러나 어렵다. 😵


Typescript Type Guard

Type Guard란?

Type Guards allow you to narrow down the type of an object within a conditional block.

조건문을 통해 특정 타입만 사용하도록 타입을 좁혀주는 것을 타입 가드라고한다.

구현

구현 자체는 어렵지 않다.

보통 조건문으로 타입을 확인하는 형태로 분기 처리해준다.

👀 타입을 확인하는 네가지 방법

  1. typeof 로 변수의 타입과 특정 타입을 비교하기

    function formatTime(x: number | string): CustomDateType {
      if (typeof x === "string") {
        // 이 블록에서 x는 무조건 string 타입
        const date = x; // ... CustomDateType으로 바꿔줄 어떤 연산
        return date;
      }
    
      const date = x; // ... CustomDateType으로 바꿔줄 어떤 연산
    
      const firstLetterOfX = x[0];
      // => **Error: There is no guarantee that `x` is a `string`**
    
      return date;
    }
    
  2. instanceof 로 변수가 특정 타입으로부터 생성된 인스턴스와 특정 타입을 비교하기

    class Common {
      common = "123";
    }
    class Foo extends Common {
      foo = 123;
    }
    class Bar extends Common {
      bar = 123;
    }
    
    function doSomething(arg: Foo | Bar) {
      let obj = {
        common: arg.common,
        whatever: null,
      };
    
      if (arg instanceof Foo) {
        obj.whatever = arg.foo; // arg.bar는 참조 불가
      }
      if (arg instanceof Bar) {
        obj.whatever = arg.bar; // arg.foo는 참조 불가
      }
    
      return obj;
    }
    
    doSomething(new Foo());
    doSomething(new Bar());
    
  3. in 키워드 사용

    interface A {
      x: number;
    }
    interface B {
      y: string;
    }
    
    function doStuff(q: A | B) {
      if ("x" in q) {
        // q: A
      } else {
        // q: B
      }
    }
    
  4. literal type인 경우 바로 비교

    type TriState = "yes" | "no" | "unknown";
    
    function logOutState(state: TriState) {
      if (state === "yes") {
        console.log("User selected yes");
      } else if (state === "no") {
        console.log("User selected no");
      } else {
        console.log("User has not made a selection yet");
      }
    }
    

user defined type guard

🥳 is 키워드를 이용해서 커스텀 타입 가드 함수를 만들 수도 있다.

type Fish = {
  swim: () => void;
};

type Bird = {
  fly: () => void;
};

/**
 * User Defined Type Guard!
 */
function GuardFishType(pet: any | Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

/**
 * 그냥 boolean값 판단 함수
 */
function isFish(pet: any | Fish | Bird): boolean {
  return (pet as Fish).swim !== undefined;
}

Boolean 판단 함수

function isFish(pet: any | Fish | Bird): boolean {
  return (pet as Fish).swim !== undefined;
}

function example(randomAnimal: any) {
  if (isFish(randomAnimal)) {
    console.log(randomAnimal + "lives in water and can" + randomAmimal.swim);
    console.log(randomAnimal.fly); // 컴파일 단에서도 전혀 문제되지 않는다.
  }
}

randomAnimal이 Fish일 때 조건 블럭이지만, randomAnimal 타입 자체는 여전히 any이다.

⇒ 일반적으로 Fish인지 아닌지를 boolean으로 판단하는 함수는 Type narrowing이 되지 않는다.

⇒ 따라서 Fish에 없는 fly 속성을 참조하려다가 컴파일 단에서는 문제가 안되겠지만, 매우 찜찜한 모양새이다. 😔

User Defined Type Guard

⇒ is 키워드를 사용하면 GuardFishType 함수가 pet 인자를 받아서 검수가 끝난 후 return으로 true를 해줄 때,
typescript가 전달받았던 변수의 타입을 Fish로 좁혀준다.

function GuardFishType(pet: any | Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function example(randomAnimal: any) {
  if (GuardFishType(randomAnimal)) {
    console.log(randomAnimal + "lives in water and can" + randomAmimal.swim);
    // Type이 좁혀진 블록이므로 접근 불가능
    // 이를 Typescript가 컴파일단에서 잡아줌
    console.log(randomAnimal.fly); // **Error: Property 'fly' does not exist on type 'Fish'**
  }

  // 사실 이 블록에서는 Typescript가 보호하지 않는 부분이기 때문에 fly에 접근해도 가능하다.
  // 컴파일에는 문제가 없으나 런타임에서 문제가 될 코드.
  console.log(randomAnimal.fly);
}

이렇게 Type Guard를 사용하면 타입 가드가 적용되는 블록에서는 컴파일에서 오류를 잡을 수 있다.

🙃 그러나, early return이 되지 않는 이상, Typescript가 보호하는 블럭은 if문 블록이기 때문에 if문 블록 밖에서 참조는 가능하므로 주의해야한다.

User Defined Type Guard 실적용! 😝

Recoil의 selector set 프로퍼티는 다른 atom을 조작할 수 있는 set 함수와 reset 함수를 제공하고 있다.

set 프로퍼티의 함수에서 인자로 넘어온 값이 없을 때 DefaultValue로 타입이 지정되는데,

이 때 인자를 타입 구분하여 set과 reset을 해주어야한다.

// DefaultValue Type guard
export function guardRecoilDefaultValue(arg: unknown): arg is DefaultValue {
  return arg instanceof DefaultValue;
}
export const atomSelector = selector<Item>({
  key: "recoil-key",
  get: ({ get }) => {
    // get이라는 함수
  },
  // **newItem: DefaultValue | Item**
  set: ({ set, reset }, newItem) => {
    // atomSelector를 reset으로 call 했을 경우 이 블럭을 진행
    if (guardRecoilDefaultValue(newItem)) {
      // **newItem: DefaultValue**
      reset(atomState);
      return;
    }

    // atomSelector를 set으로 사용했을 경우 여기를 진행
    // **newItem: Item**
    set(atomState, newItem);
  },
});

사실, 이 키워드와 기능들은 매우 단순한 예시들인데,
고도화 하려면 생각보다 머리를 많이 싸매야 해서 아직은 좀 역부족인 것 같았다..

Typescript를 진짜 다른 언어로서 공부를 많이 해야겠다고 생각했다. 😇


참고 아티클

https://www.typescriptlang.org/docs/handbook/2/conditional-types.html

https://toss.tech/article/template-literal-types

https://learntypescript.dev/09/l2-conditional-infer

https://basarat.gitbook.io/typescript/type-system/typeguard#type-guard


댓글남기기