타입 호환

타입 호환 (Type Compatibility)

TypeScript에서 타입 호환은 구조적 서브타이핑(structural subtyping)을 바탕으로 판단돼요. 구조적 타이핑은 타입을 오직 멤버만으로 비교하는 방식이라, 이름이 아니라 '모양이 같은가'가 핵심이죠. 이 글에서는 이 규칙이 어떻게 작동하는지, 그리고 어떤 상황에서 예외가 생기는지 차근차근 설명할게요.

출처: TypeScript 핸드북

소리 내어 시작하기 전에: 구조적 타이핑이란

타입 호환을 이야기하기 전에, 구조적 타이핑이 무엇인지부터 짚어볼게요. 구조적 타이핑은 타입 간의 관계를 오직 멤버만으로 판단하는 방식이에요. 이는 명목적 타이핑(nominal typing)과 정반대의 개념이죠. 다음 코드를 볼게요:

interface Pet {
  name: string;
}

class Dog {
  name: string;
}

let pet: Pet;
// OK, because of structural typing
pet = new Dog();

C#이나 Java 같은 명목적 타이핑 언어에서는 이 코드가 에러가 돼요. Dog 클래스가 자기 스스로를 Pet 인터페이스의 구현체라고 명시적으로 선언하지 않았기 때문이죠.

TypeScript의 구조적 타입 시스템은 JavaScript 코드가 실제로 쓰이는 방식에 맞춰 설계됐어요. JavaScript는 함수 표현식이나 객체 리터럴 같은 익명 객체를 널리 사용하니까, JavaScript 라이브러리에서 흔히 나타나는 관계를 표현하기엔 명목적 시스템보다 구조적 시스템이 훨씬 자연스러워요.

건전성(Soundness)에 대한 짚고 갈 점

TypeScript의 타입 시스템은 컴파일 시점엔 알 수 없는 연산을 '안전하다'고 허용하는 경우가 있어요. 타입 시스템이 이런 성질을 가지면 '건전하지 않다(unsound)'고 표현해요. TypeScript가 어디에서 비건전한 동작을 허용하는지는 신중히 고려된 결정이었고, 이 문서 전체를 통해 그 지점이 어디인지, 그리고 그 배경에 어떤 설계 동기가 있는지 설명할게요.

시작하기

TypeScript 구조적 타입 시스템의 기본 규칙은 이렇게 돼요. xy를 비교할 때, yx가 가진 멤버를 최소한 모두 갖고 있다면 xy와 호환돼요. name 프로퍼티를 가진 Pet이라는 인터페이스가 등장하는 다음 코드를 보죠:

interface Pet {
  name: string;
}

let pet: Pet;
// dog's inferred type is { name: string; owner: string; }
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
pet = dog;

dogpet에 할당할 수 있는지 확인할 때, 컴파일러는 pet의 각 프로퍼티에 대응하는 호환 가능한 프로퍼티가 dog에 있는지 하나씩 검사해요. 여기서 dog는 string 타입의 name 멤버를 갖고 있어야 하는데, 실제로 갖고 있으니 할당이 허용되는 거예요.

이 같은 할당 규칙은 함수 호출 인자를 검사할 때도 그대로 적용돼요:

interface Pet {
  name: string;
}

let dog = { name: "Lassie", owner: "Rudd Weatherwax" };

function greet(pet: Pet) {
  console.log("Hello, " + pet.name);
}
greet(dog); // OK

여기서 dogowner라는 추가 프로퍼티가 있어도 에러가 되지 않아요. 호환성 검사에서는 대상 타입(여기서는 Pet)의 멤버만 고려하기 때문이죠. 이 비교 과정은 재귀적으로 진행돼서, 각 멤버의 타입과 그 하위 멤버, 또 그 하위 멤버까지 끝까지 파고들어요.

다만 주의할 점이 하나 있어요. 객체 리터럴은 알려진 프로퍼티만 지정할 수 있어요. 예를 들어 dog를 명시적으로 Pet 타입으로 선언했기 때문에, 다음 코드는 유효하지 않아요:

let dog: Pet = { name: "Lassie", owner: "Rudd Weatherwax" }; // Error

두 함수 비교하기

기본 타입과 객체 타입을 비교하는 건 비교적 단순하지만, 어떤 함수들이 서로 호환되는지 판단하는 문제는 좀 더 복잡해요. 매개변수 목록만 다른 두 함수의 기본 예시부터 시작할게요:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error

xy에 할당 가능한지 확인하려면 먼저 매개변수 목록을 봐요. x의 각 매개변수는 y에 호환 가능한 타입의 대응 매개변수가 반드시 있어야 해요. 여기서 중요한 건 매개변수의 이름은 전혀 고려하지 않고 타입만 본다는 점이에요. 이 경우 x의 모든 매개변수는 y에 호환 가능한 대응 매개변수가 있으므로, 할당이 허용돼요.

두 번째 할당은 에러가 돼요. y는 필수 두 번째 매개변수를 갖고 있는데 x에는 그게 없으니까요. 그래서 이 방향의 할당은 불가능해요.

y = x처럼 매개변수를 '버리는' 패턴을 왜 허용하는지 궁금할 수 있어요. 그 이유는 JavaScript에서 함수의 여분 매개변수를 무시하는 게 아주 흔한 일이기 때문이에요. 예를 들어 Array#forEach는 콜백 함수에 배열 요소, 인덱스, 그리고 그 배열 자신까지 세 개의 매개변수를 전달해요. 그런데도 첫 번째 매개변수만 사용하는 콜백을 전달하는 게 매우 유용하죠:

let items = [1, 2, 3];

// Don't force these extra parameters
items.forEach((item, index, array) => console.log(item));

// Should be OK!
items.forEach((item) => console.log(item));

이번에는 반환 타입이 어떻게 처리되는지, 반환 타입만 다른 두 함수로 살펴볼게요:

let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });

x = y; // OK
y = x; // Error, because x() lacks a location property

타입 시스템은 소스 함수의 반환 타입이 대상 함수의 반환 타입의 서브타입이 되도록 강제해요.

함수 매개변수 이변성(Function Parameter Bivariance)

함수 매개변수의 타입을 비교할 때는, 소스 매개변수가 대상 매개변수에 할당 가능하든 그 반대 방향이든 어느 한쪽이라도 성립하면 할당이 성공해요. 이건 건전하지 않은 동작이에요. 호출자가 더 특수한 타입을 받는 함수를 건네받았는데, 실제로는 덜 특수한 타입으로 그 함수를 호출하게 될 수 있으니까요. 다만 실제로 이런 에러가 발생하는 경우는 드물고, 이렇게 허용하면 많은 흔한 JavaScript 패턴을 쓸 수 있게 돼요. 간단한 예를 볼게요:

enum EventType {
  Mouse,
  Keyboard,
}

interface Event {
  timestamp: number;
}
interface MyMouseEvent extends Event {
  x: number;
  y: number;
}
interface MyKeyEvent extends Event {
  keyCode: number;
}

function listenEvent(eventType: EventType, handler: (n: Event) => void) {
  /* ... */
}

// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));

// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) =>
  console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y)
);
listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>
  console.log(e.x + "," + e.y)) as (e: Event) => void);

// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));

이런 상황에서 TypeScript가 에러를 내도록 하고 싶다면 strictFunctionTypes 컴파일러 플래그를 켜면 돼요.

선택적 매개변수와 나머지 매개변수

함수의 호환성을 비교할 때 선택적 매개변수와 필수 매개변수는 서로 바꿔 쓸 수 있어요. 소스 타입에 선택적 매개변수가 여분으로 더 있어도 에러가 아니고, 대상 타입에만 있고 소스 타입에는 대응 매개변수가 없는 선택적 매개변수가 있어도 에러가 아니에요.

함수가 나머지 매개변수(rest parameter)를 가질 때는, 그걸 무한히 많은 선택적 매개변수가 있는 것처럼 취급해요.

타입 시스템 관점에선 건전하지 않은 동작이지만, 런타임 관점에서 보면 선택적 매개변수라는 개념은 대부분의 함수에서 그 자리에 undefined를 넘기는 것과 동일해서 잘 강제되지 않아요.

이를 잘 보여주는 동기가 바로, 콜백을 받아서 프로그래머에게는 예측 가능하지만 타입 시스템에는 알려지지 않은 개수의 인자로 그 콜백을 호출하는 흔한 패턴이에요:

function invokeLater(args: any[], callback: (...args: any[]) => void) {
  /* ... Invoke callback with 'args' ... */
}

// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

// Confusing (x and y are actually required) and undiscoverable
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));

오버로드가 있는 함수

함수에 오버로드가 있을 때는, 대상 타입의 각 오버로드가 소스 타입의 호환 가능한 시그니처와 반드시 매칭돼야 해요. 이렇게 함으로써 소스 함수가 대상 함수와 동일한 모든 경우에 호출될 수 있음을 보장해요.

열거형(Enums)

열거형은 숫자와 호환되고, 숫자도 열거형과 호환돼요. 다만 서로 다른 열거형 타입에서 온 열거형 값들은 서로 호환되지 않는 것으로 간주돼요. 예를 들어:

enum Status {
  Ready,
  Waiting,
}
enum Color {
  Red,
  Blue,
  Green,
}

let status = Status.Ready;
status = Color.Green; // Error

클래스(Classes)

클래스는 객체 리터럴 타입이나 인터페이스와 비슷하게 동작하지만 예외가 하나 있어요. 클래스는 정적 타입(static type)과 인스턴스 타입(instance type)을 둘 다 갖는다는 점이죠. 두 클래스 타입의 객체를 비교할 때는 오직 인스턴스의 멤버만 비교해요. 정적 멤버와 생성자는 호환성에 아무 영향을 주지 않아요.

class Animal {
  feet: number;
  constructor(name: string, numFeet: number) {}
}

class Size {
  feet: number;
  constructor(numFeet: number) {}
}

let a: Animal;
let s: Size;

a = s; // OK
s = a; // OK

클래스의 private/protected 멤버

클래스의 private 멤버와 protected 멤버는 그 클래스의 호환성에 영향을 줘요. 클래스의 인스턴스를 호환성 검사할 때, 대상 타입에 private 멤버가 있으면 소스 타입에도 같은 클래스에서 비롯된 private 멤버가 반드시 있어야 해요. protected 멤버를 가진 인스턴스도 마찬가지로 동일한 규칙이 적용돼요. 덕분에 어떤 클래스는 자기 상위 클래스와는 할당 호환이 되지만, 모양은 같아도 다른 상속 계층에 속한 클래스와는 호환되지 않아요.

제네릭(Generics)

TypeScript는 구조적 타입 시스템이기 때문에, 타입 매개변수는 멤버 타입의 일부로 소비될 때에만 결과 타입에 영향을 미쳐요. 예를 들어:

interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;

x = y; // OK, because y matches structure of x

위 예시에서 xy가 호환되는 이유는, 두 타입의 구조가 타입 인자를 구별하는 방식으로 사용하지 않기 때문이에요. 이 예시에 Empty<T>에 멤버를 하나 추가해서 바꿔 보면 어떻게 달라지는지 바로 보여요:

interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y; // Error, because x and y are not compatible

이렇게 타입 인자가 지정된 제네릭 타입은 일반(비제네릭) 타입처럼 동작해요.

타입 인자가 지정되지 않은 제네릭 타입의 경우에는, 지정되지 않은 모든 타입 인자 자리에 any를 넣어서 호환성을 검사해요. 그렇게 만들어진 결과 타입들을 일반 타입처럼 호환성 검사를 하는 거예요.

예를 들어:

let identity = function <T>(x: T): T {
  // ...
};

let reverse = function <U>(y: U): U {
  // ...
};

identity = reverse; // OK, because (x: any) => any matches (y: any) => any

고급 주제(Advanced Topics)

서브타입 vs 할당

지금까지 '호환 가능(compatible)'이라는 표현을 썼는데, 사실 이 용어는 언어 스펙에 정의된 용어가 아니에요. TypeScript에는 서브타입(subtype)과 할당(assignment)이라는 두 가지 종류의 호환성이 있어요. 둘은 오직 한 가지만 다릅니다. 할당 호환성은 서브타입 호환성에 'any와의 할당, 그리고 숫자 값이 대응되는 enum과의 할당을 허용하는 규칙'을 덧붙인 것이에요.

언어의 각 지점은 상황에 따라 이 두 호환성 메커니즘 중 하나를 사용해요. 실용적인 관점에서는 implementsextends 절의 경우에도 타입 호환성은 할당 호환성에 의해 결정돼요.

any, unknown, object, void, undefined, null, never 간의 할당 가능성

다음 표는 일부 추상 타입들 사이의 할당 가능성을 요약한 거예요. 행은 각 타입이 무엇에 할당될 수 있는지, 열은 무엇이 그 타입에 할당될 수 있는지를 나타내요. <span class='black-tick'>✓</span>strictNullChecks가 꺼져 있을 때만 호환이 되는 조합을 뜻해요.

any unknown object void undefined null never
any →
unknown →
object →
void →
undefined → ✓* ✓*
null → ✓* ✓* ✓*
never →

*strictNullChecks가 꺼져 있을 때만 호환되는 조합.

The Basics를 다시 한 번 짚어 볼게요:

  • 모든 타입은 자기 자신에게 할당 가능해요.
  • anyunknown은 '무엇이 자기한테 할당되는지'라는 관점에선 같아요. 차이는 unknownany를 제외한 어떤 것에도 할당될 수 없다는 점이에요.
  • unknownnever는 서로 역(inverse) 관계 같아요. 모든 타입은 unknown에 할당되고, never는 모든 것에 할당돼요. 반대로 never에는 아무것도 할당되지 않고, unknown은 (그냥 any 외엔) 어떤 것에도 할당되지 않아요.
  • void는 예외들을 제외하면 어떤 것에도 할당될 수도, 어떤 것에서도 할당받을 수 없어요. 그 예외는 any, unknown, never, undefined, null이에요 (strictNullChecks가 꺼져 있으면, 자세한 건 표를 참고하세요).
  • strictNullChecks가 꺼져 있으면 nullundefinednever와 비슷해져요. 대부분의 타입에 할당 가능하지만, 대부분의 타입은 그들에게 할당될 수 없어요. 그리고 둘은 서로에게 할당될 수 있어요.
  • strictNullChecks가 켜져 있으면 nullundefinedvoid처럼 동작해요. any, unknown, void를 제외하면 어떤 것에도 할당될 수 없고 어떤 것에서도 할당받을 수 없어요 (undefined는 항상 void에 할당 가능해요).

더 알아보기 (Learn more)