
Intro
첫번째 파라미터에 따라 두번째 파라미터의 타입이 달라져야 하는 경우가 있습니다.
핵심은 논리적으로 허용하지 않는 파라미터 조합을 타입 검증 단계에서 걸러져야한다는 것입니다. 이 문제를 해결하기 위한 정석적인 접근 방법은 function overload와 generic function입니다.
하지만 두 접근 방식 모두 함수 호출시에는 유효하지만 구현부에서는 여전히 타입 좁히기가 불가능하다는 문제가 남아있습니다.
이번 글에서는 호출과 구현 두 가지 상황을 모두 만족 시키는 방법에 대해 고민해보도록 하겠습니다.
Union Type
// 🔴 fruit: "apple", info: BananaInfo의 조합이 허용됩니다.
declare function logFruit(
fruit: "apple" | "banana",
info: AppleInfo | BananaInfo
): void;
가장 쉽게 생각할 수 있는 접근입니다. 얼핏 괜찮아 보이지만 banana + AppleInfo 조합을 타입 검증에서 허용한다는 문제가 있습니다.
DX를 위해선 타입이 좁혀지는게 더 나은 결과입니다. 이처럼 첫번째 파라미터에 따라 두번째 파라미터의 타입이 달라져야 하는 파라미터를 Dependent Parameter라고 합니다.
interface AppleInfo {
color: "green" | "red";
}
interface BananaInfo {
curvature: number;
}
function logFruit(fruit: "apple" | "banana", info: AppleInfo | BananaInfo) {
switch (fruit) {
case "apple":
// 🔴 구현부에서 info가 AppleInfo일 것이라 가정한다. 하지만 설계적으로 가능한 조합.
console.log(`My apple's color is ${(info as AppleInfo).color}.`);
break;
case "banana":
console.log(
`My banana's curvature is ${(info as BananaInfo).curvature}.`
);
break;
}
}
// 🟢 정상적인 조합 (apple + AppleInfo)
logFruit("apple", { color: "green" });
// 🔴 비정상 조합 (banana + AppleInfo), 그런데 타입 에러가 발생하지 않는다.
logFruit("banana", { color: "green" });
Function Overloads
function logFruitOverload(fruit: "apple", info: AppleInfo): void;
function logFruitOverload(fruit: "banana", info: BananaInfo): void;
function logFruitOverload(
fruit: "apple" | "banana",
info: AppleInfo | BananaInfo
) {
switch (fruit) {
case "apple":
// 🔴 논리적으로 apple에서 AppleInfo가 전달받아야 하지만 구현부에서는 타입 좁히기를 하지 못한다.
console.log(`My apple's color is ${(info as AppleInfo).color}.`);
break;
case "banana":
console.log(
`My banana's curvature is ${(info as BananaInfo).curvature}.`
);
break;
}
}
// 🟢 유효한 호출
logFruitOverload("apple", { color: "green" });
// 🟢 잘못된 호출, 호출 시그니처 정의를 통해 타입 검증이 되서 안전하게 호출가능하다.
logFruitOverload("banana", { color: "green" });
// No overload matches this call.
// Overload 1 of 2, '(fruit: "apple", info: AppleInfo): void', gave the following error.
// Argument of type '"banana"' is not assignable to parameter of type '"apple"'.
// Overload 2 of 2, '(fruit: "banana", info: BananaInfo): void', gave the following error.
// Argument of type '{ color: string; }' is not assignable to parameter of type 'BananaInfo'.
// Object literal may only specify known properties, and 'color' does not exist in type 'BananaInfo'.
이번엔 overload를 이용한 접근입니다.
허용 가능한 호출 시그니처를 정의하는 방법을 통해 호출시 논리적으로 불가능한 조합을 제거할 수 있었습니다. (eg. banana+AppleInfo) 구체적인 타입 에러 메세지를 통해 개발자가 디버깅을 돕는 점도 장점입니다. 다만 호출시 문제는 사라졌지만 구현부에서는 여전히 잘못된 조합에 대해 구분하지 못한다는 문제가 남아있습니다. 구현부에서도 불가능한 조합에 대해 타입 에러가 발생하면 유지보수에 더 좋겠습니다.
Generic Functions
interface InfoForFruit {
apple: AppleInfo;
banana: BananaInfo;
}
function logFruitGeneric<Fruit extends keyof InfoForFruit>(
fruit: Fruit,
info: InfoForFruit[Fruit]
) {
switch (fruit) {
case "apple":
// 🔴 구현부에서 타입 좁히기를 못한다.
console.log(`My apple's color is ${(info as AppleInfo).color}.`);
break;
case "banana":
console.log(
`My banana's curvature is ${(info as BananaInfo).curvature}.`
);
break;
}
}
// 🟢 유효한 조합
logFruitGeneric("apple", { color: "green" });
// 🟢 불가능한 조합, 타입에러 발생
logFruitGeneric("banana", { color: "green" });
// Argument of type '{ color: string; }' is not assignable to parameter of type 'BananaInfo'.
// Object literal may only specify known properties, and 'color' does not exist in type 'BananaInfo'.
제네릭 함수를 이용한 접근 방식입니다. 이 접근도 호출시에는 잘 추론되지만 함수 오버로드와 마찬가지로 구현부에서 타입 좁히기가 되지 않는 문제가 있습니다. 이유는 제네릭 타입이 런타입 분기와 연결되지 않기 때문입니다.
Rest Paramaters
type FruitAndInfo = ["apple", AppleInfo] | ["banana", BananaInfo];
function logFruitTuple(...[fruit, info]: FruitAndInfo) {
switch (fruit) {
case "apple":
// 🟢 구현부에서 타입 좁히기를 통해 어설션 제거
console.log(`My apple's color is ${info.color}.`);
break;
case "banana":
console.log(`My banana's curvature is ${info.curvature}.`);
break;
}
}
// 🟢 유효한 조합
logFruitTuple("apple", { color: "green" });
// 🟢 유효하지 않은 조합
logFruitTuple("banana", { color: "green" });
호출부와 구현부 모두 만족하는 방법을 소개합니다.
이 방식을 사용하면 구현부에서도 정확한 타입 좁히기가 가능합니다. 튜플은 순서와 조합이 고정되기 떄문에 매개변수 의존성을 나타내기 적합합니다. 한가지 아쉬움이 있다면 각 매개변수의 이름을 명시할 수 없다는 점인데, Name Tuple이 도움될 수 있습니다.
type FruitAndInfo =
| [fruit: "apple", info: AppleInfo]
| [fruit: "banana", info: BananaInfo];
function logFruitTupleNamed(...[fruit, info]: FruitAndInfo) {
switch (fruit) {
case "apple":
console.log(`My apple's color is ${info.color}.`);
break;
case "banana":
console.log(`My banana's curvature is ${info.curvature}.`);
break;
}
}
대안: Obejcts로 파라미터 결합하기
함수 인터페이스를 변경할 수 있는 상황이라면 다음 방법을 고려해봐도 좋습니다.
요청 방식이 변경된다는 점만 빼면 심플하고 효과적인 방법입니다.
interface AppleInfo {
color: "green" | "red";
}
interface AppleAndInfo {
fruit: "apple";
info: AppleInfo;
}
interface BananaInfo {
curvature: number;
}
interface BananaAndInfo {
fruit: "banana";
info: BananaInfo;
}
function logFruitTuple({ fruit, info }: AppleAndInfo | BananaAndInfo) {
switch (fruit) {
case "apple":
console.log(`My apple's color is ${info.color}.`);
break;
case "banana":
console.log(`My banana's curvature is ${info.curvature}.`);
break;
}
}
logFruitTuple({ fruit: "apple", info: { color: "green" } });
logFruitTuple({ fruit: "banana", info: { color: "green" } });
마무리
타입스크립트를 사용한다는 것만으로는 부족합니다. 인적 실수로 발생할 수 있는 상황을 구조적으로 불가능하도록 시스템화하는 것이 중요하고 최대한 컴파일 에러를 통해 조기발견될 수 있도록 유도해야 합니다. 그러기 위해선 상황에 맞는 다양한 타입 좁히기 전략이 필요합니다. 이 글이 여러분의 안전한 코드 작성에 기여했으면 하는 바람입니다.
참고
https://www.learningtypescript.com/articles/narrowing-function-parameters-with-rests-and-tuples