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" }, '')
라는 코드를 작성하면 두 번째 파라미터의 자동 완성은 name
과 sku
가 됩니다.
예시
미리 정의된 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
};
이 타입 정의에서 quantity
가 number
대신 never
가 되었지만, 이것은 완전한 해결책은 아닙니다.
왜냐하면 quantity
키 자체는 여전히 존재하고 있어 이후 이 타입을 사용하는 사람에게 약간 혼란을 줄 수 있기 때문입니다.
먼저 never
타입을 반환하는 키를 제외하려면 두 가지 단계가 필요합니다.
- 각 키가 문자열 타입을 가지고 있는지 평가합니다.
never
타입을 반환하는 키를 필터링하여 제외합니다.- 각 키가
never
가 아닌 모든 키를 얻는 방법을 코드로 설명합니다. 다음 코드는 값이 문자열 타입인 프로퍼티의 키만 추출하는 데 사용됩니다.
type StringConfigKeys = {
[Key in keyof Config]: Config[Key] extends string
? Key
: // ^ 값이 아니라 키를 반환합니다
never
}[keyof Config];
//^^^^^^^^^^^^^ { [key]: key } 객체가 아니라 모든 키를 얻습니다
TypeScript에서는 StringConfigKeys
를 'sku' | 'name'
으로 해석합니다.
이점은 quantity
키가 완전히 사라진다는 것입니다.
- 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를 보면 생성자가 타입으로 지정되지 않은 것을 알 수 있지만, 생성자도 타입으로 지정하는 방법은 어떻게 될까요?
클래스를 코딩할 때 실제로는 두 가지 다른 것이 만들어집니다.
- 생성자(Constructor): 객체를 생성하기 위해 존재하는 것(new를 사용해 호출되는 함수).
- 인스턴스(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 */;
ReturnType<F>
의 사용은 추론 가능한 함수로 제한합니다. 그렇지 않으면 객체를 전달할 때 타입이 호환되지 않다고 경고하는 대신 단순히never
를 반환합니다.- 그런 다음
infer
키워드를 사용하여 특정 위치(여기서는 함수의 반환값)에서 TypeScript가 타입을 추론할 수 있는지 확인합니다. - 만약 그렇다면 TypeScript는
infer
키워드로 정의한 타입 변수Result
에 이 타입을 설정합니다. 따라서 이를 직접 사용하여 이것이 최종 타입임을 나타냅니다. - 그렇지 않으면 다른 타입을 반환합니다. 매우 자주 "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 */;
- 항상 입력할 수 있는 파라미터를 최소화하는 것을 생각합니다(여기서는
HasValues
의 키만). - 관심 있는 값이 존재하는 곳에서
infer
키워드를 사용합니다. - TypeScript가 타입을 이해한 경우 해당 타입을 반환합니다. 여기서의 작은 특수성은 그 타입이 대문자이므로 이를 반대로 하기 위해
Uncapitalize<T>
를 사용해야 합니다. - 이해하지 못한 경우 일반적으로
never
라는 기본 타입을 반환합니다.
infer
키워드는 다양한 상황에서 사용할 수 있습니다.
특히 함수의 인수나 반환값, 템플릿 리터럴에서 사용되는 것을 볼 수 있지만, 제네릭 타입에도 적용할 수 있습니다.
예시
ElementType<T>
가 배열의 요소 타입을 추출하기 위해 어떻게 타입화될 수 있을까요?
해결책
type ElementType<A extends Array<unknown>> = A extends Array<infer Item>
? Item
: never;
요약
이러한 기술을 결합하여 타입 시스템을 세밀하게 설정할 수 있습니다.
그러나 목표는 더 관리하기 쉬운 코드베이스를 만드는 것임을 염두에 두세요.
결국 각 코드 행이 더 복잡해지고 자동화된 테스트 이상의 것을 제공하지 않는다면 더 간단한 해결책을 선택해야 합니다.
반면에 그 방법이 보안성을 높이고 코드베이스 전반에 사용되는 코드에 집중하고 있다면 지금 약간의 시간을 투자해 나중에 편하게 작업하는 것이 가치가 있습니다.
이 글을 읽은 많은 분들이 더 깊이 이해하고 일상적인 개발에 도움이 되기를 바랍니다.
'Javascript' 카테고리의 다른 글
Next.js 버전 15의 핵심 변경 사항 - 기본적으로 비활성화된 라우팅 및 데이터 캐싱 (0) | 2024.05.25 |
---|---|
자바스크립트 배열 완전 정복: 희소 배열부터 다양한 메서드 활용까지 (0) | 2024.05.17 |
TypeScript의 효율적인 타입 관리 - any 없이 타입 정의, 타입 파생 및 제네릭 마스터하기 (1) | 2024.05.15 |
TypeScript 제네릭 패턴과 활용법 - 클래스, 함수, 메서드 완벽 분석 (0) | 2024.05.15 |
TypeScript 4.5 이상 버전에서 추가된 TSConfig 옵션 살펴보기 (0) | 2024.05.12 |