타입스크립트의 타입 시스템을 공부하면서 문득 다음과 같은 궁금증이 생겼다.
원시 타입, 객체 타입은 타입 호환성을 따지는 것은 어렵지 않다. 하지만 함수는 어떻게 타입 호환성이 결정될까?
interface Person {
name: string;
age: number;
}
function setPersonInfo(person: Person) { /* ... */ }
const person = { name: '철수', age: 25, address: 'seoul' };
setPersonInfo(person); // ✅ OK, because of structural typing
여기서 person
변수에 address
속성이 있음에도 setPersonInfo
의 인자로 들어갈 수 있다. 이는 구조적 타이핑 시스템 관점에서 { name: '철수', age: 25, address: 'seoul' }
타입은 Person
타입에 있는 속성을 모두 가지고 있기 때문에 구조적으로 이 두 타입은 호환된다고 말할 수 있다.
그러면 함수 타입의 호환성은 어떻게 설명할 수 있는가? 왜 첫번째 할당문은 가능하고 두번째 할당문을 에러를 보여주는 걸까?
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // ✅ OK
x = y; // ❌ Error
type Supertype = { x: boolean }
type Subtype = { x: boolean, y: number }
let superTypedObj: Supertype;
let subTypedObj: Subtype = { x:true, y: 10 };
superTypedObj = subTypedObj; // ✅ OK, because of structural typing
Subtype
이 Supertype
의 구현체라고 명시적으로 해주지 않았기 때문이다.SubType
인 { x: boolean, y: number }
가 프로퍼티 x
를 가지고 있어 구조적 타입 시스템에서는 SubType
이 SuperType
의 부분집합(서브타입)이 되는 것이다.A는 B의 부분집합
이라는 말은 A는 B의 서브타입
, A는 B에 할당가능
, A는 B를 상속
과 모두 같은 의미이다.
하지만 아래처럼 객체 리터럴로 할당하는 경우 구조적 타입 시스템에서도 할당할 수 없다는 오류를 보여준다. 객체 리터럴을 할당하는 경우 그 타입에 있는 속성만 포함해서 할당해주도록 타입스크립트가 오류를 보여주는 것이다. 이를 잉여 속성 체크라고 부른다.
잉여 속성 체크를 통해 예상치 못한 오류를 방지해줄 수 있다.
typescript playground
interface Person {
name: string;
age: number;
}
function setPersonInfo(person: Person) { /* ... */ }
const person = { name: '철수', age: 25, address: 'seoul' };
setPersonInfo(person); // ✅ OK, because of structural typing
setPersonInfo({ name: '철수', age: 25, address: 'seoul' }); // 타입 에러 발생❗️
*덕 타이핑(duck typing)
덕 타이핑이란
동적 타이핑의 한 종류로 객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식이다. 덕 테스트에서 유래되었다. '만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다'
글 처음에 쓴 예제를 다시 가져왔다.
typescript playground
// x's type - (a: number) => number
let x = (a: number) => 0;
// y's type - (b: number, s: string) => number
let y = (b: number, s: string) => 0;
y = x; // ✅ OK
x = y; // ❌ Error
y = x
에서 x
의 파라미터가 y
의 파라미터에 모두 있어(호환되어서) y
에 x
를 할당할 수 있다.x = y
에서 y는 두번째 파라미터(s: string
)을 요구하므로 에러가 발생한다.y = x
처럼 y가 파라미터 2개를 가지고 있음에도 x가 할당 가능함
을 이미 이용하고 있었다.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));
forEach
메서드는 콜백함수의 매개변수로 (value, index, array)
이 3개를 요구하지만 3개 모두 사용하지 않아도 된다. let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });
x = y; // OK
y = x; // Error, x()가 location 프로퍼티가 없기 때문
=
)의 오른쪽 함수의 반환타입
이 왼쪽 함수의 반환타입
의 서브타입이어야 한다.y
함수의 반환타입이 { name: "Alice", location: "Seattle" }
이고 x
함수의 반환타입이 { name: "Alice" }
이므로 y
의 반환타입이 x
의 반환타입의 서브타입이다.Q. Vertex4D
는 Vertex3D
의 서브타입이고 Vertex3D
는 Vertex3D
의 서브타입일 때, 1️⃣, 2️⃣ 중 어느 것이 정상인가요? 그 이유는 무엇인가요?
interface Vertex2D {
x: number;
y: number;
}
interface Vertex3D {
x: number;
y: number;
z: number;
}
interface Vertex4D {
x: number;
y: number;
z: number;
w: number;
}
type Fn1 = (a: number, b: number, c: number, d: number) => Vertex2D
type Fn2 = (a: number, b: number, c: number) => Vertex3D
let foo: Fn1 = (arg) => {
const vertex: Vertex2D = { x: 1, y: 2 };
return vertex;
}
let bar: Fn2 = (arg) => {
const vertex: Vertex3D = { x: 1, y: 2, z: 3 };
return vertex;
}
foo = bar; // 1️⃣
bar = foo; // 2️⃣
1️⃣이 정상이다. 그 이유를 알기 위해선 foo
와 bar
의 함수 타입을 비교해야하고, 파라미터 비교와 반환타입 비교를 해보면 된다.
파라미터 비교
foo
의 파라미터는 (number, number, number, number)
이고 bar
의 파라미터는 (number, number, number)
이다. 즉, bar
의 모든 파라미터가 foo
의 파라미터와 호환가능하다(그 반대는 안됨)
반환 타입 비교
foo
의 반환 타입은 Vertex2D
, bar
의 반환 타입은 Vertex3D
이다. Vertex3D
가 Vertex2D
의 서브타입이므로 foo = bar
가 가능하다.(그 반대는 안됨)
따라서 1️⃣이 정상이다