개발/type-challenges

타입스크립트 타입 정복: Polymorphic Function

JonghwanWon 2022. 9. 23. 01:35

프로그램 언어의 다형성(多形性, polymorphism; 폴리모피즘)은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들(상수, 변수, 식, 오브젝트, 함수, 메서드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킨다.

출처 - 위키백과 다형성(컴퓨터 과학)


다형성(Polymorphism)은 프로그래밍 언어 이론에 깊이 뿌리를 둔 개념이며 다양한 종류가 있습니다.
이 글에서는 타입스크립트의 함수를 두 종류의 다형성으로 바라보고 생각합니다.
즉, 인수 타입에 따라 다르게 동작하는 함수(i.e. ad-hoc polymorphisms)와 다양한 수의 인수를 취하는 함수(i.e. parametric polymorphisms)입니다.

Union type

만약 다양한 인수를 허용하는 함수를 만들고 싶을 때 가장 직관적이고, 먼저 생각할 수 있는 방법으로 생각됩니다.

declare function doSomething(data: string | number);

인수는 문자열 또는 숫자가 될 수 있으므로, 이를 구현하기 위해 Union Type을 사용할 것입니다.
그런 다음, 함수의 body에서 지난번에 살펴보었던 Type Guards와 Narrowing을 사용해 범위를 좁히며 구현할 것입니다.
2022.08.02 - 타입스크립트 타입 정복: Type Guards와 Narrowing

만약, 반환 값의 타입이 인수의 타입에 따라 다르게 한다면 어떻게 구현해야 할까요?
Generic Type을 활용해 Conditional Type을 만들고 구현할 수 있습니다.
2022.07.12 - 타입스크립트 타입 정복: Conditional Types

function getRandom(type: 'int' | 'char') {
  if (type === 'int') {
    return Math.floor(Math.random() * 10);
  }

  return String.fromCharCode(97 + Math.floor(Math.random() * 26));
}

여기 type에 따라 생성하는 반환값이 달라지는 함수가 있습니다. 반환되는 Type은 string | number가 됩니다.
어떻게 반환 타입을 명확하게 표현할 수 있을까요?

1. 가장 먼저 인수 타입이 될 수 있는 union type('int' | 'char')을 Generic Type으로 사용합니다.
2. 이를 반환 타입에 전달해 반환 값에 대한 타입을 가져옵니다.

type GetRandomReturn<T extends 'int' | 'char'> = T extends 'int'
  ? number
  : T extends 'char'
  ? string
  : never;

function getRandom<T extends GetRandomTypes>(type: T) {
  if (type === 'int') {
    return Math.floor(Math.random() * 10) as GetRandomReturn<T>;
  }

  return String.fromCharCode(97 + Math.floor(Math.random() * 26)) as GetRandomReturn<T>;
}

결과는 아래와 같이 인수로 전달한 값이 GenericType으로 사용되어 명확한 타입을 가져올 수 있게 됩니다.
* 각 반환 값에 대해 Type Assertion하는 부분은 조금 뒤에 다룹니다.

function getRandom<"int">(type: "int"): number
function getRandom<"char">(type: "char"): string

만약, getRandom 함수에 boolean의 유형도 생성하는 기능을 추가한다고 생각해봅시다.
마찬가지로 union type으로 boolean을 추가하고, 이에 따라 반환 유형에도 conditional type을 추가해 가져올 수 있습니다.

type GetRandomReturn<T extends 'int' | 'char' | 'bool'> = T extends 'int'
  ? number
  : T extends 'char'
  ? string
  : T extends 'bool'
  ? boolean
  : never;

function getRandom<T extends 'int' | 'char' | 'bool'>(type: T) {
  if (type === 'int') {
    return Math.floor(Math.random() * 10) as GetRandomReturn<T>;
  }

  if (type === 'bool') {
    return Boolean(Math.round(Math.random())) as GetRandomReturn<T>
  }

  return String.fromCharCode(97 + Math.floor(Math.random() * 26)) as GetRandomReturn<T>;
}
function getRandom<"int">(type: "int"): number
function getRandom<"char">(type: "char"): string
function getRandom<"bool">(type: "bool"): boolean

Conditional Type을 통해 반환값을 지정하는 부분은 Indexed Access Type을 통해 변경할 수도 있습니다.

type GetRandomReturn = {
  int: number;
  char: string;
  bool: boolean;
};

function getRandom<T extends 'int' | 'char' | 'bool'>(type: T) {
  if (type === 'int') {
    return Math.floor(Math.random() * 10) as GetRandomReturn[T];
  }

  if (type === 'bool') {
    return Boolean(Math.round(Math.random())) as GetRandomReturn[T];
  }

  return String.fromCharCode(97 + Math.floor(Math.random() * 26)) as GetRandomReturn[T];
}

마찬가지로 Type Assertion을 통해 반환 값을 명시하는 부분을 볼 수 있습니다.
모든 반환 타입에 Type Assertion을 추가하는 것은 Generic Type의 모든 가능성에 대해 확인해야 하기 때문입니다.
따라서, 위 예제의 반환값에 대해 명시적으로 해야 할 필요가 있습니다.
하지만 Type Assertion은 개발자가 컴파일러에게 명시적으로 알려주는 것이기 때문에 근본적으로 안전하지는 않습니다

Optional Parameters

TypeScript에서는 ?를 통해 Optional Parameter를 표시할 수 있습니다.

declare function doSomething(a: string, b?: boolean);

즉, 함수 본문 내에서 b는 boolean이거나, undefined로 추론될 수 있습니다.
Optional Parameter가 전달되는지 여부에 따라 이러한 함수가 다른 타입의 값을 반환하는 것도 일반적으로 많이 사용됩니다.
예를 들어, 검색 결과를 비동기적으로 가져오는 검색 기능이 있다고 가정해 봅니다.
이 함수는 callback 함수를 Optional Parameter로 받아들일 수 있습니다.
만약 callback함수가 주어진다면 검색된 결과를 반환하고, callback함수가 주어지지 않는다면 promise를 반환한다고 생각해봅니다.

function search<T extends Callback | undefined = undefined>(
  keyword: string,
  callback?: T
): T extends Callback ? void : Promise<Result[]> {
  const res = searchRequest(keyword);

  if (callback) {
    res.then((data) => callback(data));
    return undefined as void & Promise<Result[]>;
  }

  return res as void & Promise<Result[]>;
}

const p = search('test'); // Promise<Result[]>
const p2 = search('test', doSomething); // void

여기서 주목해야 할 부분은, T에 대해 기본값으로 undefined를 추가해주었다는 부분입니다.
그 이유는 callback이 제공되지 않았을 때 undefined를 기본값으로 사용하여 타입스크립트 컴파일러가 적절하게 추론해 낼 수 있기 때문입니다.
만약 기본값을 추가하지 않는다면, 적절하게 추론하지 못해 아래와 같이 추론됩니다.

// 기본값이 있을 때
const p = search('test'); // Promise<Result[]>

// 기본값이 없을 때
const p = search('test'); // void | Promise<Result[]>

지금까지의 과정들을 보면 반환 유형을 결정하기 위해 조건식과 extends를 많이 사용하게 됩니다.
그리고 이 구문은 어떤 값의 추가에 따라 타입의 복잡도가 증가하게 됩니다.
이번에는 많은 타입 복잡성을 추가하지 않고 구현할 수 있는 다른 대안을 살펴봅시다.

Function Overload

JavaScript는 scope 내에서 특정 이름을 가진 하나의 함수만 허용하기 때문에 Function Overload를 할 수 없습니다.
하지만 동적으로 타입이 지정되는 언어인 JavaScript는 런타임 중 유형에 대한 검사를 수행합니다. 인수가 동적이기에 때문에 Function Overload와 동일한 효과를 얻을 수 있습니다.
즉, 호출되는 인수의 타입과 수에 따라 다른 함수 구현을 가질 수 있습니다.
JavaScript에 익숙하다면 TypeScript의 Function Overload가 부자연스럽게 느껴질 수 있습니다.

Function Overload 예시

숫자나 문자열을 인수로 받아 입력을 반대 유형으로 변환하여 반환하는 함수를 만들어 봅시다.

1. 숫자가 주어지면 문자열로 변경해 반환합니다.
2. 문자열이 주어지면 숫자로 변경해 반환합니다.

JavaScript로 작성하는 방법은 다음과 같습니다.

function switchIt(input) {
  if (typeof input === 'string') return Number(input);
  return String(input);
}

앞서 알아본 제네릭 및 조건부 유형을 사용하여 이 함수를 작성한다면 다음과 같습니다.

function switchIt<T extends string | number>(
  input: T
): T extends string ? number : string {
  if (typeof input === 'string') {
    return Number(input) as string & number;
  }
  return String(input) as string & number;
}

const num = switchIt('1'); // has type number ✅
const str = switchIt(1); // has type string ✅

같은 함수를 Function Overload로 작성한다면 다음과 같습니다.

function switchIt_overloaded(input: string): number
function switchIt_overloaded(input: number): string
function switchIt_overloaded(input: number | string): number | string {
    if (typeof input === 'string') {
        return Number(input) 
    } else {
        return String(input) 
    } 
}

Function Overload로 작성된 함수는 다음을 제거합니다.
1. 제네릭 및 조건부 유형
2. 타입단언(type assertion)

그리고 다음과 같은 이점 을 얻을 수 있습니다.
1. 가독성
오버로드된 함수는 인수 및 반환 값의 유형은 별도로 명시적으로 작성되기 때문에 가능한 변형을 명확하게 구별할 수 있습니다.
2. 오버로드된 함수에 대한 IDE 지원이 더 좋습니다.

React에서의 예

React의 useState 훅도 사용하기 쉽도록 오버로드되었습니다.
초기 값이나 값을 반환하는 함수가 있는 경우 상태는 해당 값의 유형이 됩니다.

const [state] = useState(1) // number

초기 값 전달을 건너뛰고 대신 타입을 지정할 수도 있습니다. state는 Union 타입이 됩니다.

const [state] = useState<number>() // number | undefined

타입을 지정하지 않으면 undefined가 표시됩니다.

const [state] = useState() // undefined

이는 함수 오버로드를 통해 표현되는 것을 알 수 있습니다. 참고

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];

Type Assertion은 종종 code smell로 간주되며 Function Overload를 활용하여 이를 제거하는 것이 좋아 보일 수 있습니다.
그러나 Function Overload는 Type Assertion과 마찬가지로 안전하지 않습니다.

예제로 돌아가서 switchIt_overloaded 함수가 의도적으로 잘못된 유형을 반환하도록 구현을 엉망으로 만들어 봅시다.

function switch_overloaded(input: string): number
function switch_overloaded(input: number): string
function switch_overloaded(input: number | string): number | string {
     if (typeof input === 'string') {
        return input // input is still a string when it should be converted to number
    } else {
        return input // input is still a number when it should be converted to string
    } 
}

const num = switch_overloaded('1') // ❌ num's type is number but it is actually a string
const str = switch_overloaded(1) // ❌ str's type is string but it is actually a number

TypeScript 컴파일러는 (오버로드된) 함수 시그니처에 대해 함수 본문의 코드만 확인하지만 if, else 분기가 어떤 개별 오버로드를 처리해야 하는지는 알 수 없습니다.

결론

결과적으로 우리는 모순되는 코드를 작성할 수 있고 TypeScript는 우리를 도울 수 없습니다.
Function Overload를 선호하든 조건부 유형이 있는 제네릭 유형을 선호하든 이에 대해 매우 의도적이어야 하며
둘 다 완전히 안전하지 않기 때문에 매우 조심스러워야 합니다.


Reference

Documentation - More on Functions

Learn about how Functions work in TypeScript.

www.typescriptlang.org

Zhenghao's site

The official site of Zhenghao He, a software engineer and a TypeScript/JavaScript enthusiast.

www.zhenghao.io

What are Function Overloads in TypeScript?

A Simple Explanation of Function Overloading in TypeScript, Helps You Understand Overload Signatures and Implementation Signature.

javascript.plainenglish.io

Using TypeScript — Function Return Values and Overloads

Return types and function overloads.

levelup.gitconnected.com