Javascript

TypeScript 고급 타입 마스터하기 - 객체 키 추출부터 infer 활용까지

드리프트2 2024. 5. 15. 22:20

 

TypeScript의 고급 타입 마스터하기

TypeScript의 고급 타입 마스터하기

지난주에 TypeScript의 3가지 황금 규칙(독자적인 해석)에 대해 설명했습니다.

 

그 중 세 번째 규칙인 "타입의 파생을 우선한다: 범용 타입(제네릭)"은 특히 구현하기 어려운 부분입니다.

 

타입 시스템이 효과적으로 기능하는 이유는 그 정의가 정확하기 때문입니다.

 

하지만 정확할수록 타입의 중복이 늘어나고, 코드가 유지보수 불가능해질 위험이 커집니다.

 

이에 대한 해결책은 파생(=다른 타입에서 타입을 정의하는 것)을 활용하는 것입니다.

 

하지만 이는 간단하지 않습니다. 그래서 오늘은 타입을 최적으로 설정하기 위한 몇 가지 중요한 개념을 단계별로 소개하려고 합니다.

 

이 개념들을 점차 이해함으로써 코드의 임의의 부분에 타입을 적용하는 데 자립할 수 있을 것입니다.

다룰 개념

  • 객체의 키를 얻기
  • 객체의 키 필터링
  • 클래스를 다루기
  • 문자열 조작
  • infer를 사용해 기존 타입에서 타입 추출

객체의 키를 얻기

목적: 변수의 타입을 제한하여 객체의 키로 직접 사용할 수 있도록 하기

 

몇 가지 키로 구성된 객체의 예를 생각해봅시다.

type Config = {
  sku: string;
  name: string;
  quantity: number;
};

 

위의 설정을 토대로 두 번째 파라미터로 객체 키 중 하나를 받는 getConfig 함수를 만들어봅시다.

function getConfig(config, key) {
  return config[key];
}

 

TypeScript를 사용하지 않는 경우, key는 무엇이든 가능하며 foo처럼 사용 가능한 설정에 포함되지 않은 것도 포함될 수 있습니다.

 

따라서 key를 단순한 문자열이 아닌 가능한 조합의 유니언으로 제한하는 것을 고려합니다.

// ❌ 수동으로 작성하는 것을 피하세요
type ConfigKey = 'sku' | 'name' | 'quantity';

 

이 유니언을 자동으로 얻으려면 keyof 키워드를 사용해야 합니다.

// ✅ keyof를 선호합니다
type ConfigKey = keyof Config;

 

키의 타입을 얻는 방법을 알았으니 함수로 돌아가 봅시다. 키와 반환 값을 올바르게 타입화하려면 어떻게 해야 할까요?

 

키의 타입은 앞서 본 것처럼 keyof Config입니다.

 

하지만 반환 값의 타입은 어떨까요?

 

첫 번째 반응으로는 Config[keyof Config]를 사용하는 것일 수 있습니다.

// ❌ 기대한 대로 작동하지 않습니다...
function getConfig(config: Config, key: keyof Config): Config[keyof Config] {
  return config[key];
}

 

이것은 작동하지 않을 것입니다.

 

왜냐하면 Config[keyof Config]를 분해하면 다음과 같아지기 때문입니다.

Config[keyof Config]
<=> Config['sku' | 'name' | 'quantity']
<=> Config['sku'] | Config['name'] | Config['quantity']
<=> string | string | number
<=> string | number

 

만약 getConfig(config, 'quantity')를 실행한 경우, 반환 값이 number 하나만 되길 기대하지만 string이 반환되지 않습니다.

 

따라서 제네릭을 사용해야 합니다.

function getConfig<Key extends keyof Config>(
  config: Config,
  key: Key
): Config[Key] {
  return config[key];
}

 

이제 getConfig(config, 'quantity')를 실행하면 반환 값은 quantity의 값이 됩니다.

 

여기서 Key의 타입은 사용 가능한 유니언 값 중 하나이며, 유니언 전체가 아닙니다.

 

이 메서드로 key 파라미터는 config 객체의 키만 될 수 있으므로 반환 값은 그 키의 타입으로 제한됩니다.

 

이를 통해 안전성을 확보하고 매우 정밀한 자동 완성이 가능합니다.

getConfig(
  { sku: '123', name: 'Product' },
  'name'
);

 

IDE에서 getConfig({ sku: "123", name: "Product" }, '')라는 코드를 작성하면 두 번째 파라미터의 자동 완성은 namesku가 됩니다.

 

예시

미리 정의된 Config 타입을 사용하고 있습니다.

 

모든 객체에 대응할 수 있도록 하려면 어떻게 해야 할까요?

 

해결책

새로운 타입 파라미터를 추가합니다.

 

이를 통해 함수에 전달된 객체에 맞게 설정을 적용할 수 있습니다.

function getConfig<
  Config extends Record<string, unknown>,
  Key extends keyof Config
>(config: Config, key: Key): Config[Key] {
  return config[key];
}

 

그러나 주의가 필요합니다.

 

모든 형태의 설정에 대해 작동하는 함수를 만들고 싶은 경우에만 의미가 있습니다.

 

애플리케이션 전체에서 하나의 설정 형식만 있다면 굳이 제네릭으로 만들 필요는 없을 것입니다.

객체의 키 필터링

목적: 객체의 타입을 변환하는 방법 배우기

이전 글에서 Omit<T, key>라는 유틸리티 타입에 대해 이야기했습니다.

 

이는 객체에서 하나 이상의 키를 제외하는 데 사용됩니다.

type Config = {
  sku: string;
  name: string;
  quantity: number;
};

type LimitedConfig = Omit<Config, 'quantity'>;
// { sku: string, name: string }

 

여러 키를 제외하는 방법은 다음과 같습니다.

type LimitedConfig = Omit<Config, 'quantity' | 'name'>;
// { sku: string }

 

특정 값의 타입을 기준으로 키를 선택하려면 새로운 타입을 만들어 필터링할 수 있습니다.

 

예를 들어 값이 문자열 타입인 키만 얻고 싶다면 다음과 같이 타입을 정의할 수 있습니다.

 

우선, Config 타입의 모든 키를 가지고 각각의 키에 대해 Config와 동일한 타입을 가지는 새로운 타입인 StringConfig를 만듭니다.

 

여기서 값이 문자열인 경우만 필터링하는 조건을 추가합니다.

// ❌ 아직 완전하지 않습니다
type StringConfig = {
  [Key in keyof Config]: Config[Key]
};

 

문제는 현재 값이 필터링되지 않고 있다는 것입니다.

 

이를 해결하기 위해 TypeScript에 다음과 같이 지시합니다. 만약 Config[Key]가 문자열 타입이라면 그것을 사용하고, 그렇지 않으면 never 타입을 할당하여 해당 키를 사용할 수 없음을 TypeScript에 알립니다.

// ❌ 아직 완전하지 않습니다
type StringConfig = {
  [Key in keyof Config]: Config[Key] extends string ? Config[Key] : never
};

 

이 타입 정의에서 quantitynumber 대신 never가 되었지만, 이것은 완전한 해결책은 아닙니다.

 

왜냐하면 quantity 키 자체는 여전히 존재하고 있어 이후 이 타입을 사용하는 사람에게 약간 혼란을 줄 수 있기 때문입니다.

 

먼저 never 타입을 반환하는 키를 제외하려면 두 가지 단계가 필요합니다.

  1. 각 키가 문자열 타입을 가지고 있는지 평가합니다.
  2. never 타입을 반환하는 키를 필터링하여 제외합니다.
  3. 각 키가 never가 아닌 모든 키를 얻는 방법을 코드로 설명합니다. 다음 코드는 값이 문자열 타입인 프로퍼티의 키만 추출하는 데 사용됩니다.
type StringConfigKeys = {
  [Key in keyof Config]: Config[Key] extends string
    ? Key
    : // ^ 값이 아니라 키를 반환합니다
      never
}[keyof Config];
//^^^^^^^^^^^^^ { [key]: key } 객체가 아니라 모든 키를 얻습니다

 

TypeScript에서는 StringConfigKeys'sku' | 'name'으로 해석합니다.

 

이점은 quantity 키가 완전히 사라진다는 것입니다.

  1. 1단계에서 정의된 키만 가져와 전체 객체를 다시 구성하려면 유틸리티 타입 Pick을 사용할 수 있습니다.
type StringConfig = Pick<Config, StringConfigKeys>;

 

이것이 적절하게 필터링된 새로운 타입입니다 .

 

예시

매번 이런 처리를 하는 것은 그다지 실용적이지 않습니다.

 

대신에 다음과 같은 타입을 어떻게 작성하시겠습니까?

  • RemoveNeverValues<O>: 객체 O를 받아 값이 never인 키를 제외한 새로운 타입을 반환합니다.
  • FilterByValue<O, V>: 객체 O를 받아 값이 타입 V와 같은 타입인 키만 유지하는 새로운 타입을 반환합니다.

다음과 같이 작성할 수 있습니다.

type StringConfig = FilterByValue<Config, string>;

 

해결책

// 값이 never인 모든 키를 제거
type RemoveNeverValues<T> = Pick<
  T,
  {
    [K in keyof T]: T[K] extends never ? never : K
  }[keyof T]
>;

// 타입 T에서 값이 V인 모든 키를 제거
type FilterByValue<T, V> = RemoveNeverValues<{
  [Key in keyof T]: T[Key] extends V ? T[Key] : never
}>;

// 이 필터링된 타입은 잘 작동합니다 ✅
type StringConfig = FilterByValue<Config, string>;
// { sku: string, name: string }

클래스를 다루기

목적: 클래스에서 사용 가능한 메서드를 추출하기

 

객체지향 프로그래밍에서 클래스에서 기대되는 메서드를 설명하기 위해 인터페이스를 사용하는 것이 일반적입니다.

 

다음과 같은 형태를 띱니다.

type ClockInterface = {
  getCurrentTime(): Date;
};

class Clock implements ClockInterface {
  currentTime: Date = new Date();

  constructor(h: number, m: number) {
    // 구현
  }

  getCurrentTime() {
    return this.currentTime;
  }
}

 

만약 "인터페이스"와 "타입"의 차이를 궁금해한다면 여기를 클릭하세요.

 

ClockInterface를 보면 생성자가 타입으로 지정되지 않은 것을 알 수 있지만, 생성자도 타입으로 지정하는 방법은 어떻게 될까요?

 

클래스를 코딩할 때 실제로는 두 가지 다른 것이 만들어집니다.

  1. 생성자(Constructor): 객체를 생성하기 위해 존재하는 것(new를 사용해 호출되는 함수).
  2. 인스턴스(Instance): instance = new Class()를 실행한 후 조작하는 객체.

생성자의 타입을 표현하려면 다음과 같이 작성해야 합니다.

type ClockConstructor = new (hour: number, minute: number) => Clock;

 

일반적으로 타입 변수를 도입할 때 이 제네릭 타입을 사용할 수 있습니다.

export type Constructor<T> = new (...args: unknown[]) => T;

// 정적 프로퍼티를 추가
export type ConstructorWithStatics<
  T,
  S extends Record<string, unknown>
> = Constructor<T> & S;

 

반대로 생성자로부터 인스턴스를 얻고 싶다면 TypeScript의 네이티브 타입을 사용할 수 있습니다.

export type Clock = InstanceType<ClockConstructor>;

문자열 조작하기

목적: 객체 내에서 동적 키를 생성하기

 

앞서 본 것처럼 string 타입은 'sku' | 'name' | 'quantity' 타입과 동일하지 않습니다.

 

따라서 문자열 관리에는 더 많은 안전을 제공할 수 있습니다.

 

특히 이를 가능하게 하는 것이 TypeScript의 템플릿 리터럴 관리입니다.

 

JavaScript에서 템플릿 리터럴은 다음과 같은 구문을 가집니다.

const hello = `Hello ${name}`;

 

마찬가지로 TypeScript에서는 타입 레벨에서 같은 것을 할 수 있습니다.

type WithId<S extends string> = `${S}Id`;

type UserId = WithId<'user'>;
// type: "userId"

 

객체에 동적으로 키를 추가하려면 어떻게 해야 할까요?

type Values = {
  name?: string;
  quantity?: number;
};

type HasValues = {
  hasName: boolean;
  hasQuantity: boolean;
};

 

문자열 "XXX"를 "hasXXX"로 변환하는 방법을 설명합니다.

 

템플릿 리터럴을 사용하여 has를 접두사로 붙일 수 있습니다.

 

하지만 hasname이 아니라 hasName으로 만들고 싶기 때문에 필요한 대문자를 추가하기 위해 Capitalize를 사용해야 합니다.

type HasNameKey = `has${Capitalize<'name'>}`;
// type 'hasName'

 

객체의 각 키에 대해 이를 수행해야 합니다. 따라서 다음과 같은 코드가 됩니다.

type HasValues = {
  [Key in `has${Capitalize<keyof Values>}`]: boolean;
};

 

이상하게 보일 수 있지만 이는 keyof 키워드의 위치와 관련이 있습니다.

 

일반적인 코딩에서는 for (let key of values)처럼 작성하고 그 내부에서 변환을 수행합니다.

 

그러나 여기서는 그 반대처럼 보입니다. keyof가 모든 변환 내부에 있습니다.

type Key = Capitalize<'name' | 'quantity'>;

type Key = Capitalize<'name'> | Capitalize<'quantity'>;

 

각 경우를 직접 작성하고 중간에 |를 두는 대신 가능한 한 내부에 |를 두어 모두 다시 작성할 필요가 없게 해야 합니다.

 

그리고 'name' | 'quantity' 유니언을 어떻게 얻을까요? keyof를 사용합니다.

Capitalize<keyof Values>;

infer를 사용해 기존 타입에서 타입 추출하기

목적: 기존 타입에서 내부 타입을 얻기

 

마지막으로 타입 시스템을 잘 이해하기 위해 정말 중요한 요소는 한 타입에서 다른 타입을 추출하는 방법입니다.

 

이를 통해 타입을 잘 다루고 제네릭에 전달하는 타입 변수의 수를 제한할 수 있습니다.

 

이를 배우기 위해 함수가 어떻게 타입화되어 있는지 먼저 살펴봅시다.

function sum(a: number, b: number): number;

 

실제로 타입 정의에만 집중하면 다음과 같은 스타일이 됩니다.

type Sum = (a: number, b: number) => number;

 

 Sum에서 반환 타입을 어떻게 얻을 수 있을까요?

 

만약 TypeScript의 문서를 이미 봤다면 ReturnType이라는 유틸리티 타입이 있다는 것을 눈치챘을 것입니다.

 

하지만 가정에서 이 유틸리티 타입을 다시 코딩한다면 어떻게 작동할까요?

type ReturnType<F extends Function> = ???;

 

첫 번째 방법은 이전과 동일하게 하는 것입니다.

 

제약을 조금 더 자세히 설명하여 그 일부를 사용할 수 있도록 합니다.

 

따라서 Function을 아래와 같이 대체합니다.

// ❌ 작동하지 않습니다
type ReturnType<F extends (...args: unknown[]) => Result> = Result;

 

문제는 현재 Result 타입의 변수가 존재하지 않는다는 것입니다.

 

만약 추가해야 한다면 그것은 F 앞에 있어야 합니다. 그러나 사용할 수 있는 것은 Sum뿐입니다.

 

해결책은 infer 키워드에 있습니다. 이는 TypeScript에 타입을 추론하게 하고 추론에 성공했는지 여부에 따라 그 타입을 사용하도록 지시합니다.

 

좀 더 구체적으로 말하면 추론에 실패한 경우를 관리하기 위해 다음과 같은 조건을 만들 수 있습니다.

// ✅ OK
type ReturnType<
  /* 1 */
  F extends (...args: never[]) => unknown
> =
  /* 2 */
  F extends (...args: never[]) => infer Result
    ? Result /* 3 */
    : never /* 4 */;
  1. ReturnType<F>의 사용은 추론 가능한 함수로 제한합니다. 그렇지 않으면 객체를 전달할 때 타입이 호환되지 않다고 경고하는 대신 단순히 never를 반환합니다.
  2. 그런 다음 infer 키워드를 사용하여 특정 위치(여기서는 함수의 반환값)에서 TypeScript가 타입을 추론할 수 있는지 확인합니다.
  3. 만약 그렇다면 TypeScript는 infer 키워드로 정의한 타입 변수 Result에 이 타입을 설정합니다. 따라서 이를 직접 사용하여 이것이 최종 타입임을 나타냅니다.
  4. 그렇지 않으면 다른 타입을 반환합니다. 매우 자주 "never"로 불가능함을 나타냅니다. 이 경우에는 /1/에서 입력으로 받을 수 있는 타입을 정확하게 타겟팅하기 때문에 이 조건의 측면에 절대 떨어지지 않습니다. 그러나 이를 명시해야 합니다.

 

눈치챘을지 모르지만 ...args: unknown[] 대신 ...args: never[]을 썼습니다.

 

이것은 분산 처리(variance)와 관련이 있습니다.

 

그 차이는 중요하지만 이해하는 것은 그리 중요하지 않습니다. 작동하는 것을 사용하세요 😁

좋습니다만 기존 코드를 재사용했을 뿐입니다.

 

약간 다른 컨텍스트에서 어떻게 적용할 수 있는지 살펴봅시다.

 

이를 위해 앞선 예를 다시 살펴봅시다.

type Values = {
  name?: string;
  quantity?: number;
};

type HasValues = {
  [Key in `has${Capitalize<keyof Values>}`]: boolean;
};

 

HasValues의 키에서 원래 키로 돌아갑니다.

BaseValueKey<'hasName'> // name
BaseValueKey<'hasQuantity'> // quantity

 

BaseValueKey를 어떻게 타입화할지에 대해 동일한 4단계를 사용합니다.

type BaseValueKey<
  /* 1 */
  HasKey extends keyof HasValues
> =
  /* 2 */
  HasKey extends `has${infer Key}`
    ? Uncapitalize<Key> /* 3 */
    : never /* 4 */;
  1. 항상 입력할 수 있는 파라미터를 최소화하는 것을 생각합니다(여기서는 HasValues의 키만).
  2. 관심 있는 값이 존재하는 곳에서 infer 키워드를 사용합니다.
  3. TypeScript가 타입을 이해한 경우 해당 타입을 반환합니다. 여기서의 작은 특수성은 그 타입이 대문자이므로 이를 반대로 하기 위해 Uncapitalize<T>를 사용해야 합니다.
  4. 이해하지 못한 경우 일반적으로 never라는 기본 타입을 반환합니다.

infer 키워드는 다양한 상황에서 사용할 수 있습니다.

 

특히 함수의 인수나 반환값, 템플릿 리터럴에서 사용되는 것을 볼 수 있지만, 제네릭 타입에도 적용할 수 있습니다.

 

예시

 

ElementType<T>가 배열의 요소 타입을 추출하기 위해 어떻게 타입화될 수 있을까요?

 

해결책

type ElementType<A extends Array<unknown>> = A extends Array<infer Item>
  ? Item
  : never;

요약

이러한 기술을 결합하여 타입 시스템을 세밀하게 설정할 수 있습니다.

 

그러나 목표는 더 관리하기 쉬운 코드베이스를 만드는 것임을 염두에 두세요.

 

결국 각 코드 행이 더 복잡해지고 자동화된 테스트 이상의 것을 제공하지 않는다면 더 간단한 해결책을 선택해야 합니다.


반면에 그 방법이 보안성을 높이고 코드베이스 전반에 사용되는 코드에 집중하고 있다면 지금 약간의 시간을 투자해 나중에 편하게 작업하는 것이 가치가 있습니다.

 

이 글을 읽은 많은 분들이 더 깊이 이해하고 일상적인 개발에 도움이 되기를 바랍니다.