TypeScript

TypeScript 구조적 타이핑에 익숙해지기

2pandi 2024. 3. 20. 16:05

자바스크립트는 본질적으로 '덕 타이핑(duck typing)' 기반이다.
덕 타이핑은 객체가 어떤 타입에 부합하는 변수와 메서드를 가지는 경우 객체를 해당 타입에 속하는 것으로 간주하는 것이다.
어떤 함수에 매개변수 값이 요구사항을 만족한다면 타입이 무엇인지 신경쓰지 않는다는 것인데
타입스크립트는 이런 동작을 그대로 모델링한다.

 

구조적 타이핑을 제대로 이해한다면 오류인 경우와 오류가 아닌 경우의 차이를 알 수 있고, 더욱 견고한 코드를 작성할 수 있다.

 

어떤 2D 벡터 타입을 다룬다고 가정해보자.

interface Vector2D {
  x: number;
  y: number;
}

 

벡터의 길이를 계산하는 함수는 다음과 같다.

function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y)
}

 

name이라는 속성이 추가된 NamedVector를 하나 추가한다.

interface NamedVector {
  name: string;
  x: number;
  y: number;
}

 

NamedVector에는 number 타입의 x와 y 속성이 있기 때문에 calculateLength 함수로 호출이 가능하다.

const v: NamedVector = { x: 3, y: 4, name: 'Zee' };
calculateLength(v); // 정상 작동한다. -> 5

 

여기서 Vector2D와 NamedVector의 관계는 전혀 선언되지 않았지만,

NamedVector를 위한 별도의 calculateLength 함수를 구현할 필요가 없다.

이는 TS가 JS의 런타임 동작을 모델링 하기 때문이다.

NamedVector의 타입 구조가 Vector2D의 타입 구조에 호환되기 때문에 calculateLength함수 호출이 가능한 것이다.

이것을 '구조적타이핑(structural typing)' 이라고 한다.

 

3D 벡터를 새로 만든다.

interface Vector3D {
  x: number;
  y: number;
  z: number;
}

 

그리고 벡터의 길이를 1로 만드는 정규화 함수를 작성한다.

function normalize(v: Vector3D) {
  const length = calculateLength(v);
  return {
    x: v.x / length,
    y: v.y / length,
    z: v.z / length,
  };
}

 

그러나 이 함수를 실행해보면 1보다 긴 잘못된 결과를 출력하게 된다.

normalize({x: 3, y: 4, z: 5}); // 잘못된 결과 -> {x: 0.6, y: 0.8, z: 1} -> 길이 1.41

 

calculateLength는 2D 벡터를 기반으로 연산하는데,

normalize는 3D 벡터를 기반으로 연산하기 때문에 z가 정규화에서 무시되는 오류가 발생한 것이다.

 

타입체커는 이러한 문제를 잡아내지 못한다.

Vector3D 타입의 {x, y, z} 객체로 calculateLength를 호출하면,

구조적 타이핑 관점에서 x와 y가 있기 때문에 Vector2D와 호환되기 때문이다.

 

함수를 작성할 때 호출에 사용되는 매개변수의 속성들이 매개변수의 타입에 선언된 속성만을 가질 것이라고 착각하기 쉽다.

이러한 타입을 '봉인된(sealed)' 또는 '정확한(precise)' 타입이라고 부르는데,

타입스크립트 타입 시스템에서는 이를 표현할 수 없다.

즉, 타입스크립트의 타입 시스템은 '열려(open)'있다는 것이다.

 

구조적 타이핑은 클래스와 관련된 할당문에서도 비슷한 결과를 보여준다.

class C {
  foo: string;
  constructor(foo: string) {
  this.foo = foo;
  }
}

const c = new C('instance of C')
const d: C = { foo: 'object literal' }; // 정상

 

d가 C 타입에 할당되어도 문제가 되지 않는다.

이는 d가 string 타입의 foo 속성을 가지고 있고,

Object.prototype으로부터 비롯된 constructor또한 가지고 있기 때문이다.

즉, 구조적으로 필요한 속성과 생성자가 존재하기 때문이다.

 

만약 C의 constructor에 단순 할당이 아닌 연산 로직이 존재한다면

d의 경우 생성자를 실행하지 않으므로 문제가 발생할 수 있다.