Method Type Signature

TypeScript에서 메서드의 타입을 정의하는 방법은 두 가지가 있습니다.

interface Store<T> {
  set: (item: T) => void;
}
interface Store<T> {
  set(item: T): void;
}

여러분들은 둘 중 어떤 방식을 선호하시나요?

저는 전자, 그러니까 프로퍼티로서 메서드를 선언하는 방식을 사용해왔습니다.

지금껏 이런 선언 방식은 개인 스타일이라고 생각해서 다른 사람이 저와 다른 방식으로 메서드를 선언하더라도 크게 신경쓰지 않았어요. 그런데 지난 금요일에 메서드 타입 선언 스타일에 대해서 논의하다가, 이 두 가지 방식이 실제로는 미묘한 차이를 가지고 있다는 것을 알게 되었습니다.

지금부터 두 가지 방법이 어떻게 다른지 하나씩 알아볼게요.

--strictFunctionTypes

TypeScript 2.6부터 --strictFunctionTypes 라는 옵션이 추가되었는데요. 이미 --strict 옵션을 사용한다면 기본적으로 적용되고 있을 겁니다.

--strict 옵션이 그런 것처럼 --strictFunctionTypes 도 TypeScript의 특정한 동작을 허용하지 않도록 만드는데요. --strictFunctionTypes 가 허용하지 않는 동작은 다음과 같습니다.

type Adder = (a: string | number, b: string | number) => string | number;

let add: Adder;

// `--strictFunctionTypes`를 사용하면 Error
add = (a: number, b: number) => {
  return a + b;
};

위 코드는 --strictFunctionTypes 옵션을 켜면 에러가 발생합니다. 사실 너무 당연한 동작 같아보여요. stringnumber 를 모두 처리할 수 있어야 하는 함수에, number 타입만 처리할 수 있는 함수를 할당하는 것이니까요. 이게 할당된다면 당연히 Type-Safe하지 않겠죠. 오히려 이걸 끌 수 있는 "옵션"씩이나 만들어주는게 좀 의아하게 느껴질 정도에요.

이 기능이 처음 소개된 Pull Request에서는 --strictFunctionTypes 를 다음과 같이 소개하고 있습니다.

With this PR we introduce a --strictFunctionTypes mode in which function type parameter positions are checked contravariantly instead of bivariantly.

여기에서 contravariantly, bivariantly라는 말이 나오는데 이게 뭔지 조금 더 깊이 알아보죠.

공변성(Covariance)과 반공변성(Contravariance)

contravariantly를 한글로 번역하면 "반공변적으로"라고 표현할 수 있겠습니다. 즉, 앞서 나온 --strictFunctionTypes 의 소개는 함수의 파라미터 타입이 반공변적으로 동작하도록 변경한다는 의미가 되겠죠.

여기서 말하는 반공변성을 설명하기 전에, 그 반대개념인 공변성(Covariance)에 대해서 짚고 넘어가야 할 것 같습니다.

공변성

let array: Array<string | number> = [];
let stringArray: Array<string> = [];

array = stringArray; // OK
stringArray = array; // Error

위의 예제에서 보면, arraystringArray 를 할당하는 것은 문제가 없습니다. 반대로 stringArrayarray 를 할당하려고 하면 타입 에러가 발생하죠.

string | numberstring 을 포함할 수 있습니다. 즉, stringstring | number 의 서브타입이라고 표현할 수 있는데요. 마찬가지로 Array<string | number> 역시 Array<string> 을 포함할 수 있는 타입이 됩니다.

이렇게 AB 의 서브타입일 때, T<A>T<B> 의 서브타입이 된다면, T공변적이라고 부를 수 있습니다. 따라서 위의 예제는 Array 의 공변성을 보여주는 예제라고 할 수 있죠.

반공변성

앞서 --strictFunctionTypes 를 사용하면 함수 파라미터가 반공변적으로 동작한다고 말씀드렸죠. 실제로 반공변성은 어떻게 동작하는지 코드로 살펴볼까요?

type Logger<T> = (param: T) => void;

let log: Logger<string | number> = (param) => {
  console.log(param);
};

let logNumber: Logger<number> = (param) => {
  console.log(param);
};

log = logNumber; // Error
logNumber = log; // OK

위의 예제는 앞서 살펴보았던 Array 와는 정확히 반대로 동작합니다. logNumberlog 를 할당하는 것은 문제가 없지만, loglogNumber 를 할당하려고 하면 에러가 발생합니다. 이건 당연하죠. logNumberstring 타입을 커버하지 못하니까요.

numberstring | number 의 서브타입임에도 불구하고, Logger<string | number> 가 오히려 Logger<number> 의 서브타입이 되는 셈입니다. 이런 식으로 동작하는 것을 반공변성이라고 표현할 수 있습니다. 공변성과는 반대인 셈이죠.

--strictFunctionTypes 를 사용하면 함수의 파라미터가 모두 반공변적으로 동작합니다.

이변성(Bivariance)

--strictFunctionTypes 를 쓰지 않으면 TypeScript는 파라미터를 이변적으로(bivariantly) 다룹니다. 이변성은 공변성과 반공변성을 모두 가지는 것을 말합니다. 즉, 서브타입과 수퍼타입 모두 파라미터로 사용하더라도 타입 에러가 나지 않도록 만듭니다.

type Logger<T> = (param: T) => void;

let log: Logger<string | number> = (param) => {
  console.log(param);
};

// OK
log = (param: string | number | boolean) => {
  console.log(param);
};

// OK
log = (param: number) => {
  console.log(param);
};

이 예제에서, Logger<string | number> 의 서브타입은 Logger<string | number | boolean> 일 뿐만 아니라, Logger<number> 이기도 하다는 것입니다.

이런 동작은 확실히 비상식적으로 보입니다. string | number 를 처리해야 하는 함수에 string | number | boolean 까지 처리할 수 있는 함수를 할당하는 건 문제가 없지만, number 만 처리할 수 있는 함수를 할당하게 된다면 런타임에 타입 문제가 생길 것이 자명합니다. 해당 함수는 모든 파라미터를 number 로 보고 처리할텐데 실제로 string 타입까지 들어올 수 있는 거니까요, Type-Safe 하지 않죠.

그럼 이쯤에서 자연스럽게 의문이 듭니다. 함수 파라미터는 반공변적으로 구현하는게 당연한 것 같은데, 왜 그전에는 반공변적으로 동작하지 않았을까?

함수 파라미터가 이변성을 가진 이유

let array: Array<string | number> = [];
let stringArray: Array<string> = [];

array = stringArray; // OK
stringArray = array; // Error

공변성을 설명할 때 보여드렸던 예제입니다. 여기서 Array<string>Array<string | number> 의 서브타입처럼 동작하고, 논리적으로도 그렇게 동작하는 것이 합당하다고 생각되지만 실은 그렇게 간단한 문제가 아닙니다.

JavaScript의 Array 인스턴스는 다양한 메서드를 가집니다. 그 중에서 push 는 다음과 같은 타입 시그니처를 가지고 있습니다.

interface Array<T> {
  // ...
  // 원래는 `push(...items: T[]): number;` 같은 형태지만, 설명 편의상 시그니처를 변경합니다.
  push(item: T): number;
  // ...
}

Array<string>push 메서드는 (item: string) => number 타입입니다. 마찬가지로, Array<string | number>push 메서드는 (item: string | number) => number 타입이죠.

그런데 앞서 설명했듯 논리적으로 생각해봤을때 함수 파라미터는 반공변적으로 동작하는 것이 적절합니다. 그러니까, Array<string | number>.pushArray<string>.push 를 할당할 수는 없는 겁니다. 결과적으로 Array<string>Array<string | number> 의 서브타입이 될 수 없죠. push 메서드를 할당할 수 없으니까요.

하지만 직관적으로 Array<string | number>Array<string> 을 포함하는 개념인 것은 자명합니다. 사실 이런 문제를 해결하기 위해서 다른 언어(C#, Scala)에서는 공변성, 반공변성을 선언하는 문법을 가지고 있는데요. TypeScript는 이런 문법을 가지고 있지 않다보니 Type Safety를 일정 부분 희생하고, Array 등을 지원하기 위해 이변성을 선택한 것으로 보입니다.

여기까지 살펴보니, 또 하나의 의문이 생겼습니다. 지금은 --strictFunctionTypes 옵션을 항상 켜두고 TypeScript를 쓰는데, 왜 Array 에서 별다른 문제가 없었던 걸까요? 지금의 Array 는 어떤 방식으로 동작하고 있는 걸까요?

같지만 다르다

다음과 같은 Store 인터페이스가 있다고 가정해보겠습니다.

interface Store<T> {
  set: (item: T) => void;
}

--strictFunctionTypes 옵션을 켠 채로 다음과 같은 코드를 작성하면 에러가 나게 됩니다.

const store: Store<number | string> = {
  // Error
  set(item: number) {
    // ...
  },
};

앞서 설명했던 대로 --strictFunctionTypes 가 켜지면 메서드도 반공변적으로 동작하기 때문에, 이 같은 동작은 예상했던 결과겠죠.

그런데 Store 인터페이스를 다음과 같이 약간 수정하게 되면,

interface Store<T> {
  set(item: T): void;
}

더 이상 에러가 발생하지 않습니다!

정말 이상해보이지만 이것은 사실 의도된 동작인데요. 다시 --strictFunctionTypes 를 소개한 Pull Request에서는 메서드를 이렇게 이상하게(?) 다루게 된 이유를 설명합니다.

Methods are excluded specifically to ensure generic classes and interfaces (such as Array<T>) continue to mostly relate covariantly.

그러니까, 앞서 언급했던 Array 등이 공변적으로 동작하도록 만들어 두기 위해서 이런 구멍(?)을 만들어 두었다고 설명하고 있습니다.

실제로도 lib.es5.d.ts 를 열어보면 Arraypush 메서드가 다음과 같이 줄여쓰기 문법을 사용해 정의되어 있습니다.

interface Array<T> {
  // ...
  push(...items: T[]): number;
  // ...
}

정리하자면, 줄여쓰기(shorthand) 방식(set(item: T): void;)은 메서드 파라미터를 이변적으로 동작시키기 위한 표기법이고, 프로퍼티 방식(set: (item: T) => void;)은 메서드 파라미터를 반공변적으로 동작시키기 위한 표기법이라고 볼 수 있겠습니다.

여기까지 설명하고 나니 공변성, 반공변성을 표기법을 도입하지 않은 이유는 뭘까 궁금해서 찾아보았는데, 이 개념이 직관적으로 이해하기 어려워서라는 답변이 달린 GitHub 이슈를 발견했네요. TypeScript에 이거 말고도 이해가 어려운 개념들 많은데.. 더 관심이 있으신 분들은 읽어보시기 바랍니다.

우리가 해야할 일

실제로 코딩을 하면서 메서드 파라미터를 반드시 이변적으로 동작하도록 만들어야 할 일은 거의 없을텐데요. 그렇기 때문에 대부분의 경우에는 반공변적으로 동작하는 프로퍼티 방식의 메서드 타입 정의를 사용하는 것이 Type Safety 측면에서는 더 좋을 것 같습니다.

프로퍼티 방식의 메서드 타입 정의를 강제하도록 만드는 ESLint 룰이 있는데요. 바로 method-signature-style 룰 입니다. 평소에는 이 룰을 활성화해놓고 스타일 고민없이 작성하면 될 것 같습니다. (물론 줄여쓰기 방식으로도 강제하는 것이 가능합니다. 추천드리지는 않지만요.)

읽어주셔서 감사합니다.

더 읽어보기

이 글을 쓰면서 참고한 링크들입니다.