Typescript 타입 시스템 찍먹하기: 타입 호환성

오정진 Jeongjin Oh·2023년 7월 4일
1

타입스크립트의 타입 시스템을 공부하면서 문득 다음과 같은 궁금증이 생겼다.

원시 타입, 객체 타입은 타입 호환성을 따지는 것은 어렵지 않다. 하지만 함수는 어떻게 타입 호환성이 결정될까?

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

TL;DR

  • 타입 호환성은 구조적 타이핑에 기반한다. 구조적 타입핑은 상속이나 인터페이스 구현(implemantation) 없이 멤버만을 기준으로 타입을 연관시킨다.
  • 구조적 타이핑에서 발생하는 예상치 못한 오류를 막기 위해 타입스크립트는 잉여 속성 체크라는 과정을 거친다. 이는 객체 리터럴을 할당할 때만 작동한다.
  • 함수 타입 간 타입 호환성을 따지는 것은 원시 타입이나 객체 타입에서보다 좀 더 복잡하다.
  • 함수 타입의 타입 호환성은 파라미터 목록과 반환 타입으로 결정할 수 있다.

타입 호환성이란

  • 타입호환성(Type compatibility)은 구조적 서브타이핑(structural subtyping)에 기반한다. 구조적 타이핑은 멤버만을 기준으로 타입을 연관시킨다.
  • 이는 명목적 타이핑(nominal typing)과 대조된다.
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
  • C# 이나 Java같이 nominal typing 기반 언어는 위 상황에서 에러가 발생할 것이다. 왜냐하면 SubtypeSupertype의 구현체라고 명시적으로 해주지 않았기 때문이다.
  • 타입스크립트의 구조적 타입시스템은 자바스크립트의 *덕 타이핑(duck typing)을 모델링한 것이다(이펙티브 타입스크립트 p.26)
  • 따라서 위 예시에서 SubType{ x: boolean, y: number }가 프로퍼티 x를 가지고 있어 구조적 타입 시스템에서는 SubTypeSuperType의 부분집합(서브타입)이 되는 것이다.

  • 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의 파라미터에 모두 있어(호환되어서) yx를 할당할 수 있다.
  • 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개 모두 사용하지 않아도 된다.

반환 타입 비교

typescript playground

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. Vertex4DVertex3D의 서브타입이고 Vertex3DVertex3D의 서브타입일 때, 1️⃣, 2️⃣ 중 어느 것이 정상인가요? 그 이유는 무엇인가요?

typescript playground

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 = (arg: Vertex4D) => Vertex2D
type Fn2 = (arg: Vertex3D) => 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️⃣이 정상이다. 그 이유를 알기 위해선 foobar의 함수 타입을 비교해야하고, 파라미터 비교와 반환타입 비교를 해보면 된다.

  1. 파라미터 비교
    foo의 파라미터는 (number, number, number, number)이고 bar의 파라미터는 (number, number, number)이다. 즉, bar의 모든 파라미터가 foo의 파라미터와 호환가능하다(그 반대는 안됨)

  2. 반환 타입 비교
    foo의 반환 타입은 Vertex2D, bar의 반환 타입은 Vertex3D이다. Vertex3DVertex2D의 서브타입이므로 foo = bar 가 가능하다.(그 반대는 안됨)

따라서 1️⃣이 정상이다

참고

profile
단 한사람의 불편함이라도 해결해 줄 수 있는 개발자가 되고 싶습니다.

0개의 댓글