TypeScript 핸드북 9 - 타입 호환성

타입 호환성 (Type Compatibility)

소개

TypeScript의 타입 호환성은 구조적인 하위 타입을 기반으로 합니다. 구조적 타이핑(Structural typing)은 멤버에게만 의존하여 타입을 연관시키는 방법입니다. 이는 Nominal typing과는 대조적입니다. 다음 코드를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
interface Named {
name: string;
}
class Person {
name: string;
}
let p: Named;
// OK, because of structural typing
p = new Person();

Person 클래스가 Named 인터페이스의 구현체라고 명시적으로 기술하지 않았기 때문에 C#이나 Java와 같은 명목적 선언 언어에서는 위와 같은
코드는 오류가 발생합니다.

TypeScript의 Structural 타입 시스템은 일반적으로 JavaScript 코드가 작성된 방식에 따라 설계되었습니다. JavaScript는 함수 표현식 및 객체 리터럴과 같은 익명 객체를 광범위하게 사용하기 때문에 Nominal 타입 시스템 대신에 Structural 타입 시스템을 사용하여 JavaScript 라이브러리에서 발견되는 관계(Relationship)의 종류를 표현하는 것이 훨씬 자연 스럽습니다.

명목상의 Structural 타입 시스템 대신 JavaScript 라이브러리에서 발견되는 관련성(Relationship)를 표현하는 것이 훨씬 자연스럽습니다.

건전성에 대한 메모 (Note on Soundness)

TypeScript의 타입 시스템을 사용하면 컴파일 타임에 알 수없는 특정 작업을 수행할 수 있습니다. 하지만 타입 시스템에 이 프로퍼티가 있으면 건전하지 않습니다. TypeScript는 건전하지 않는 동작을 허용하는 장소를 신중하게 고려했으며, 이 문서 전체에서 이러한 상황이 발생하는 곳과 그 뒤에있는 동기 부여 시나리오에 대해 설명합니다.

시작하기

TypeScript Structural 타입 시스템의 기본 규칙은 y가 적어도 x와 같은 멤버를 가지고 있다면 xy와 호환된다는 것입니다.

1
2
3
4
5
6
7
8
interface Named {
name: string;
}
let x: Named;
// y's inferred type is { name: string; location: string; }
let y = { name: "Alice", location: "Seattle" };
x = y;

yx에 할당 될 수 있는지를 검사하기 위해, 컴파일러는 x의 각 프로퍼티을 검사하여 y에서 호환되는 대응 프로퍼티를 찾습니다. 이 경우y는 문자열인 name 멤버를 가져야합니다. 그렇기 때문에 위 코드는 할당이 허용됩니다.

함수 호출 파라미터를 검사할 때도 동일한 할당 규칙이 사용됩니다.

1
2
3
4
function greet(n: Named) {
alert("Hello, " + n.name);
}
greet(y); // OK

y는 여분의 location 속성을 가지고 있지만 위 코드는 오류를 발생하지 않습니다. 호환성을 검사할 때 대상 타입(이 경우 Named)의 멤버만 고려됩니다.

이러한 비교 프로세스는 재귀적으로 진행되어 각 구성원 및 하위 구성원의 타입을 탐색합니다.

두 함수의 비교

Primitive 타입과 객체 타입을 비교하는 것은 비교적 간단하지만, 어떤 종류의 함수가 호환 가능하다고 판단해야 하는지에 대한 질문은 좀더 복잡합니다. 파라미터 목록이 다른 함수의 두개의 기본 예제부터 살펴 보겠습니다.

1
2
3
4
5
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 예제에서 처럼 폐기(discarding) 파라미터를 허용하는 이유가 궁금할 수 있습니다. 이 할당이 허용되는 이유는 JavaScript에서 함수의 추가 파라미터를 무시하는 것이 실제로 매우 일반적이기 때문입니다. 예를 들어, Array#forEach는 콜백 함수에 세개의 파라미터, 즉 배열 요소, 해당 인덱스, 포함 배열을 제공합니다. 그럼에도 불구하고 첫 번째 파라미터만 사용하는 콜백을 제공하는 것은 매우 일반적입니다.

1
2
3
4
5
6
7
let items = [1, 2, 3];
// 이러한 추가 매개 변수를 강제로 사용하지 마십시오.
items.forEach((item, index, array) => console.log(item));
// 괜찮습니다.
items.forEach(item => console.log(item));

이제 리턴 타입만 다른 두 함수를 사용하여 리턴 타입을 처리하는 방법을 살펴 보겠습니다.

1
2
3
4
5
let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});
x = y; // OK
y = x; // x()에 location 프로퍼티가 없기 때문에 오류가 발생합니다.

타입 시스템은 원본 함수의 리턴 타입이 대상 함수 리턴 타입의 서브 타입이 되도록 강제합니다.

함수 파라미터의 Bivariance

함수 파라미터의 타입을 비교할 때 원본 파라미터가 대상 파라미터에 할당 가능하거나 그 반대인 경우 할당이 성공합니다. 호출자가 더 특수화된 타입을 취하는 함수를 제공하게 될 수도 있기는 하지만 덜 특수화된 타입의 함수를 호출할 수 있기 때문에 이것은 불리합니다. 실제로 이러한 종류의 오류는 거의 발생하지 않으며 이를 통해 많은 일반적인 JavaScript 패턴을 사용할 수 있습니다. 간단한 예를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum EventType { Mouse, Keyboard }
interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// 정상적이지 않지만 유용하고 일반적입니다.
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));
// 정상적인 상태에서 원하지 않는 대안
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + "," + (<MouseEvent>e).y));
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + "," + e.y)));
// 여전히 허락되지 않습니다. (명백한 에러). 모든 호환 타입에 대해 타입의 안정성을 강제합니다.
listenEvent(EventType.Mouse, (e: number) => console.log(e));

Optional 파라미터와 Rest 파라미터

호환성을 위해 함수를 비교할 때 Optional과 Required 파라미터는 서로 바꿔서 사용할 수 있습니다. 원본 타입의 Extra optional 파라미터는 오류가 아니고, 원본 타입의 해당 파라미터가 없는 대상 타입의 Optional 파라미터는 오류가 아닙니다.

함수가 Rest 파라미터를 가지면 무한한 일련의 Optional 파라미터인 것처럼 취급됩니다.

이것은 타입 시스템의 관점에서 볼 때 불만족 스럽지만, 런타임 관점에서 Optional 파라미터의 개념은 일반적으로 잘 시행되지 않습니다. 그자리에 undefined를 전달하는 것은 대부분의 함수들이 일반적이기 때문입니다.

이러한 동기를 부여하는 예제는 콜백을 가져 와서 (프로그래머는) 예상할 수 있지만 (타입 시스템은) 알 수없는 파라미터의 개수로 호출하는 함수의 일반적인 패턴입니다.

1
2
3
4
5
6
7
8
9
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... Invoke callback with 'args' ... */
}
// Unsound - invokeLater "might"는 여러 가지 파라미터를 제공합니다.
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
// 혼란 스럽고 (x와 y는 실제로 필요합니다) 발견할수 없음
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));

함수의 Overload

함수에 Overload가 있는 경우 원본 타입의 각 Overload는 대상 타입의 호환 가능한 Signature와 일치 해야합니다. 이렇게 하면 원본 함수와 동일한 모든 상황에서 대상 함수를 호출할 수 있습니다.

Enum

enum은 number와 호환되며 number는 enum과 호환됩니다. 다른 enum에서 가져온 값은 호환되지 않는 것으로 간주됩니다.

1
2
3
4
5
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let status = Status.Ready;
status = Color.Green; //error

클래스

클래스는 객체 리터럴 타입 및 인터페이스와 유사하게 작동하지만 한 가지 예외는 정적 및 인스턴스 유형을 모두 포함한다는 것입니다. 클래스 타입의 두 객체를 비교할 때 인스턴스의 멤버만 비교됩니다. 정적 멤버 및 생성자는 호환성에 영향을주지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 멤버

클래스의 privateprotected 멤버는 호환성에 영향을 줍니다. 클래스의 인스턴스가 호환성을 검사할 때 대상 타입에 private 멤버가 포함되어 있으면 원본 타입에 동일한 클래스에서 생성된 private 멤버가 포함되어야 합니다. 마찬가지로 protected 멤버가있는 인스턴스에도 동일하게 적용됩니다. 클래스는 슈퍼 클래스에 할당이 가능하지만 다른 상속 계층 구조의 클래스에서는 할당이 안됩니다.

Generic

TypeScript는 Structural 타입 시스템이므로 Type 파라미터는 멤버 타입의 일부로 소비될 때 결과 타입에만 영향을줍니다.

1
2
3
4
5
6
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // okay, y matches structure of x

위의 경우, xy의 구조는 Type 파라미터를 차별화된 방식으로 사용하지 않기 때문에 호환 가능합니다. Empty<T>에 멤버를 추가하여 이 예제를 변경하면 어떻게 동작하는지 살펴 보겠습니다.

1
2
3
4
5
6
7
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // error, x and y are not compatible

이런 식으로 Type 파라미터가 지정된 Generic 타입은 Non-generic 타입처럼 동작합니다.

Type 파라미터가 지정되지 않은 Generic 타입의 경우, 지정되지 않은 모든 Type 파라미터 대신에 any를 지정하여 호환성을 검사합니다. 그 결과 생성된 타입은 Non-generic 경우와 마찬가지로 호환성을 검사합니다.

1
2
3
4
5
6
7
8
9
let identity = function<T>(x: T): T {
// ...
}
let reverse = function<U>(y: U): U {
// ...
}
identity = reverse; // Okay because (x: any)=>any matches (y: any)=>any

고급 주제

Subtype vs Assignment

지금까지는 ‘호환 가능성(compatible)’이라는 단어를 사용했습니다. 이는 TypeScript의 사양에 정의된 용어가 아닙니다. TypeScript에는 하위 타입과 할당이라는 두 가지 종류의 호환성이 있습니다. 이것들은 할당이 subtype 호환성의 규칙을 확장하여 any 부터 enum 까지 또는 그 반대에 상응하는 숫자값과 함께 할당할 수 있도록합니다.

TypeScript의 다른 측면은 상황에 따라 두 가지 호환 메커니즘 중 하나를 사용합니다. 실제적인 목적을 위해 타입 호환성은 implementsextend 절의 경우에도 할당 호환성에 따라 결정됩니다. 자세한 내용은 TypeScript 사양을 참조하십시오.


이 내용은 나중에 참고하기 위해 제가 공부하며 정리한 내용입니다.
의역, 오역, 직역이 있을 수 있음을 알려드립니다.
This post is a translation of this original article [https://www.typescriptlang.org/docs/handbook/type-compatibility.html]

참고

공유하기