TypeScript, 타입의 Key 이름을 변경하기

다음과 같은 타입이 있습니다.

type Person = {
    name: string;
    age: number;
}

위의 타입을 다음처럼 변경하고자 합니다.

{
    my_name: string;
    my_age: number;
}

가장 간단한 방법은 다음과 같습니다.

type Prefix<T> = {
    [K in keyof T as `my_${string & K}`]: T[K]
}

type NPerson = Prefix<Person>;

이 글의 목적은 TypeScript 4.1에서 처음 제공되는 as 연산자입니다. as 연산자를 활용한 또 다른 예로써 특정 타입을 갖는 키를 갖는 타입을 정의하는 것입니다.

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type NPerson2 = OnlyStrings<Person>;

위 코드 중 NPerson2는 다음과 같은 타입이 됩니다.

{
    name: string;
    // age: number; -> 제거
}

TypeScript를 이용한 TLP 예제 – 두 정수의 합

두 정수의 합을 타입 레벨(Type Level)에서 구할 수 있을까? 예를들어 다음과 같은 코드가 필요합니다.

type Result1 = Add<3, 4>; // 💡 타입 추론 결과: 7
type Result2 = Add<12, 8>; // 💡 타입 추론 결과: 20

Add라는 타입을 통해 두 정수의 합을 계산합니다. Add 타입에 대한 코드는 다음과 같습니다.

// N의 길이를 가지는 튜플 타입을 생성하는 헬퍼
type BuildTuple<Length extends number, Arr extends unknown[] = []> =
    Arr['length'] extends Length ? Arr : BuildTuple<Length, [...Arr, unknown]>;

// 두 숫자의 배열을 합쳐 전체 길이를 구하는 방식으로 덧셈 구현
type Add<A extends number, B extends number> =
    [...BuildTuple<A>, ...BuildTuple<B>]['length'];

위의 코드를 분석할 때 필요한 사전 지식은 다음과 같습니다. 튜플 타입의 길이는 타입 수준에서 미리 계산할 수 있다는 것이고 tupleType['length']처럼 얻을 수 있습니다.

TypeScript, 인자값에 대한 문자열 형식 강제하기

예를들어 다음과 같은 예제코드가 있습니다.

setPadding('10px'); // ✅
setPadding('2.5em'); // ✅
// setPadding('10');   // ❌ 에러: 'px', 'em', '%' 단위가 빠졌습니다.
// setPadding('tenpx'); // ❌ 에러: 숫자가 아닙니다.

setPadding이라는 함수는 길이에 대한 값을 받는데 숫자값과 단위가 조합된 하나의 문자열 값입니다. 이를 위해 setPadding 함수는 다음처럼 정의될 수 있습니다.

type CSSSize = `${number}px` | `${number}em` | `${number}%`;

function setPadding(padding: CSSSize) {
  console.log(padding);
  return { padding };
}

TypeScript, 함수 오버로딩을 스마트하게 대체하는 “매개변수 동적 추론”

하나의 함수에 대해서 첫번째로 입력받는 인자의 값에 따라 이후 인자의 타입을 결정할 수 있는 방법입니다. 함수 오버로딩이 아닌 TypeScript 본연의 타입 기능을 이용하는 방법으로 함수 오버로딩보다 더욱 작성하기 쉽고 똑똑합니다. 예를들어 emit라는 함수를 다음처럼 정의합니다.

type EventMap = {
  click: { x: number; y: number };
  hover: { elementId: string };
  keyPress: { key: string; code: number };
};

// Key를 제네릭으로 받아 EventMap[Key]를 Payload 타입으로 자동 추론합니다.
function emit<K extends keyof EventMap>(eventName: K, payload: EventMap[K]) {
  console.log(`Event: ${eventName}`, payload);
}

emit의 첫번째 값에 따라 두번째 인자의 타입이 다르게 결정됩니다. 해당 타입들은 위의 EventMap 타입에 정의되어 있습니다. 이제 다음처럼 emit 함수를 이용할 수 있습니다.

emit('click', { x: 10, y: 20 });        // ✅ 정상 작동
emit('click', { elementId: 'btn' });    // ❌ 타입 에러! click은 x, y가 필수입니다.
emit('hover', { elementId: 'header' }); // ✅ 정상 작동 (elementId 오타 시 감지)

그러나 emit 함수 구현부에서 첫번째 인자인 eventName의 값에 따라 payload의 타입을 결정해서 사용할 수 있어야 합니다. 예를들어 eventName이 ‘click’이라면 payload.x가 가능해야 합니다. 하지만 위의 방법으로는 안됩니다. 다른 방법은 다음과 같으며 TypeScript 코드로써 보다 더 쉽고 직관적입니다.

type EventMap =
  | {
    eventName: "click";
    payload: { x: number; y: number };
  }
  | {
    eventName: "hover";
    payload: { elementId: string };
  }
  | {
    eventName: "keyPress";
    payload: { key: string; code: number };
  };

function emit(event: EventMap) {
  if (event.eventName === "click") {
    event.payload.x; // ✅
    event.payload.y;
  }

  if (event.eventName === "hover") {
    event.payload.elementId; // ✅
  }
}

emit({ eventName: 'click', payload: { x: 10, y: 20 } });

결국 함수 오버로딩을 스마트하게 대체하는 “매개변수 동적 추론” 방식은 크게 의미가 없습니다. 🙂

TypeScript, 유니온 타입에서 타입 융합을 방지하는 방법

타입 융합이란, 예를들어 'red' | string 과 같은 타입의 경우 'red'string에 포함되는 타입이므로 결국 string 하나로 융합되는 것을 의미합니다. 이런 융합을 막기 위한 방법은 string 대신 string & {}을 사용하는 것입니다. 관련된 예제 코드는 다음과 같습니다.

// string & {} 를 교차(Intersection)시키면 타입 융합(Type Flattening)을 방지합니다.
type Color = 'red' | 'blue' | 'green' | (string & {});

function setColor(color: Color) {
  console.log(`Setting color to ${color}`);
}

setColor("red")
// 💡 효과:
// 1. setColor('') 입력 시 'red', 'blue', 'green' 팝업 자동완성이 뜹니다.
// 2. setColor('#123456') 처럼 임의의 문자열을 넣어도 타입 에러가 발생하지 않습니다.