- Published on
Type parameter와 control flow analysis
⚠ TS v5.4.5 기준으로 작성됨
상황
union type에 대해서 공통 속성을 추가하고 싶을 때가 있다
예를 들어 다음과 같은 union type이 있다고 하자
type Knight = {
tag: 'Knight';
sword: 'great-sword';
};
type Priest = {
tag: 'Priest';
wand: 'magic-wand';
};
type Character = Knight | Priest;
union을 사용하다보면 공통 속성을 추가하고 싶을 때가 있다
// Character에 공통 속성(hp, damage) 추가
type CharacterWithCommon = Character & { hp: number; damage: number };
function createCharacter(character: Character): CharacterWithCommon {
switch (character.tag) {
case 'Knight':
return {
...character,
hp: 100,
damage: 100,
};
case 'Priest':
return {
...character,
hp: 50,
damage: 50,
};
}
}
이렇게 하면 요구사항을 충족할 수 있으나 한 가지 불편한 점이 있다
declare const knigt: Knight;
// @ts-expect-error
const result: Knight = createCharacter(knigt);
Knight를 input으로 줬으니 반환값은 당연히 Knight에 대입할 수 있는 객체다
그러나 타입 상으론 createCharacter의 반환 타입은 Knight | Priest
이며 Priest는 Knight에 대입할 수 없기 때문에 타입 오류가 발생한다
따라서 caller가 직접 타입을 좁혀야 한다
declare const knigt: Knight;
const result = createCharacter(knigt);
if (result.type === 'Knight') {
const modified: Knight = result;
// ...
}
매개변수 타입에 따라서 반환 타입도 정해졌으면 한다
이럴 때 generic으로 구현하면 된다
매개변수 타입을 T라 하면 반환 타입은 CharacterWithCommon 중에서 T와 매칭되는 타입이 될 것이다
function createCharacter<T extends Character>(
character: T
): Extract<CharacterWithCommon, { tag: T['tag'] }> {
switch (character.tag) {
case 'Knight':
// @ts-expect-error
return {
...character,
hp: 100,
damage: 100,
};
case 'Priest':
// @ts-expect-error
return {
...character,
hp: 50,
damage: 50,
};
}
}
declare const knight: Knight;
const result: Knight = createCharacter(knight);
이렇게 하면 의도한 대로 작동한다
문제는 함수 내부에서 타입 오류가 발생한다는 거다
왜 타입 오류가 나는 걸까?
원인
타입 오류가 나는 원인은 TS 컴파일러가 변수의 타입은 좁힐 수 있지만 타입 매개변수를 좁히지는 못하기 때문이다
다시 코드를 보자
function createCharacter<T extends Character>(
character: T
): Extract<CharacterWithCommon, { tag: T['tag'] }> {
switch (character.tag) {
case 'Knight':
// 여기에서 character는 Knight로 좁혀진다
// -> T는 Knight다
// -> 반환 타입은 Knight & { hp: number; damage: number }로 추론된다
// -> 따라서 타입 오류는 나지 않아야 한다
return {
...character,
hp: 100,
damage: 100,
};
case 'Priest':
return {
// ...
};
}
}
이렇게 논리적으로는 타입 오류가 안 나는 게 맞다
그러나 TS 컴파일러가 T를 Knight로 추론하지 못하기 때문에 반환 타입도 Knight & { hp: number; damage: number }
로 추론되지 않는다
따라서 타입 오류가 발생하는 것이다
해결방법
Function overloads
함수 구현부에서 오류가 나니까 함수 오버로딩으로 타입을 느슨하게 만들면 타입 오류를 피할 수 있다
function createCharacter<T extends Character>(
character: T
): Extract<CharacterWithCommon, { tag: T['tag'] }>;
function createCharacter(character: Character): CharacterWithCommon {
switch (character.tag) {
case 'Knight':
return {
...character,
hp: 100,
damage: 100,
};
case 'Priest':
return {
...character,
hp: 50,
damage: 50,
};
}
}
declare const knight: Knight;
const result: Knight = createCharacter(knight);
물론 함수 오버로딩은 함수 구현부에서 타입 안전을 보장하지 않는다
function createCharacter<T extends Character>(
character: T
): Extract<CharacterWithCommon, { tag: T['tag'] }>;
function createCharacter(character: Character): unknown {
switch (character.tag) {
case 'Knight':
// 타입 오류가 발생하지 않는다
return null;
case 'Priest':
return null;
}
}
declare const knight: Knight;
// 런타임에서 null
const result: Knight = createCharacter(knight);
따라서 별로 좋은 방법은 아니다
Dispatch table
switch의 각 case를 함수로 만들고 dispatch table을 경유하도록 만들면 타입 안전하게 작성할 수 있다
이 경우 distributive object type을 이용해야 한다
참고) Correlated union
// 기존 타입을 map으로 표현
type CharacterMap = {
Knight: {
sword: 'great-sword';
};
Priest: {
wand: 'magic-wand';
};
};
// 기존 union을 distributive object type으로 표현
type CharacterUnion<T extends keyof CharacterMap = keyof CharacterMap> = {
[K in T]: {
tag: K; // <- 판별 속성이 타입 변수로 있어야 한다
} & CharacterMap[K];
}[T];
type CharacterWithCommonUnion<T extends keyof CharacterMap = keyof CharacterMap> = {
[K in T]: CharacterUnion<K> & { hp: number; damage: number };
}[T];
type CreateCharacterMap<T extends keyof CharacterMap = keyof CharacterMap> = {
[K in T]: (character: CharacterUnion<K>) => CharacterWithCommonUnion<K>;
};
// switch문의 각 case를 함수로
const createCharacterMap: CreateCharacterMap = {
Knight: (character) => ({ ...character, hp: 100, damage: 100 }),
Priest: (character) => ({ ...character, hp: 50, damage: 50 }),
};
function createCharacter<T extends keyof CharacterMap>(
character: CharacterUnion<T>
): CharacterWithCommonUnion<T> {
return createCharacterMap[character.tag](character);
}
declare const knight: Knight;
const result: Knight = createCharacter(knight);
이렇게 하면 타입 안전하게 의도한 바를 이룰 수 있다
결론
그냥 union을 쓰자
generic과 union은 잘 섞이지 않는다
이 둘을 같이 쓰면서 타입 안전하게 만드려면 distributive object type같은 트릭을 써야 한다
그런데 이런 트릭은 가독성이 안 좋다
이런 상황이 생긴다면 union을 쓰고 조건문으로 타입을 좁혀서 사용하도록 하자
전체 코드