avatar
Published on

타입스크립트의 구조 타이핑

Author
  • avatar
    Name
    yceffort

타입스크립트의 타입 체크는 가끔 내가 생각하는 것보다 광범위 해서 생각치도 못한 결과를 만들어 낼 때가 있다. 아래와 같은 코드가 있다고 가정해보자.

interface Vector2D {
  x: number
  y: number
}

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

그리고 새로운 interface를 만들고, 이를 함수의 값으로 넣었다.

interface Vector2DWithName extends Vector2D {
  name: string
}

const a: Vector2DWithName = { name: 'hi', x: 5, y: 10 }
calcLength(a) // works fine

interface Vector2DName {
  name: string
  x: number
  y: number
}

const b: Vector2DName = { name: 'hello', x: 10, y: 10 }
calcLength(b) // works fine, too.

Vector2DWithName야 뭐, extends로 그 둘 사이에 관계가 어떻게 어떻게 보였다고 치더라도, Vector2DNameVector2D 둘 사이에는 별다른 관계가 선언되거나 한적이 없음에도 정상적으로 작동하는 것을 볼 수 있다.

타입스크립트의 타입시스템은 '구조적으로' 타입이 맞기만 한다면 이를 허용해준다. 여기서 등장한 용어가 바로 structural typing 이다. (뭐 제대로된 번역을 본적이 없어서 그냥 구조적 타이핑이라고 하겠다.)

일반적으로 다른 언어, C#, Java 등에서는 허용하지 않는 방법이다. 그러나 이 때문에 예기치 못한 문제를 만들어 낼 수 있다.

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

function normalize(v: Vector3D) {
    const length = calcLength(v) // z가 고려되지 않음
    return {
        x: v.x / length,
        y: v.y / length,
        z: v.z / length // z의 값이 이상하게 나옴
    }
}

normalize({x:3, y:4, z:5}) // 그러나 에러는 안남

일반적으로 함수를 작성할 때, 함수에서 들어오는 인수가 애초에 원하는 대로 의도한 인수만 가지고 있고, 그 외의 값은 안올 것이라고 기대하고, 그게 일반적이다. 그러나 타입스크립트의 타입 시스템에서는 그렇지 않다. 타입스크립트의 타입은 열려있기 때문이다. 이러한 함정(?) 때문에, 타입스크립트를 처음 접하게 되면 아래와 같은 실수를 많이 범하게 된다.

function calcLengthV1(v: Vector3D) {
  let length = 0
  for (const axis of Object.keys(v)) {
    const coord = v[axis] // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Vector3D'.
    // No index signature with a parameter of type 'string' was found on type 'Vector3D'.(7053)
    length += Math.abs(coord)
  }
  return length
}

내가 선언한 v의 키 값들은 x,y,z로 잘되어 있고, 이는 모두 string이라 잘 들어가야 한다. 그리고 값들도 number라서 length에 잘 더할 수 있어야 한다. 그런데 왜 에러가 나지?

https://yceffort.kr/2021/05/do-not-use-suppressImplicitAnyIndexErrors 에서 한번 다룬적이 있다.

그러나, 위에서 이야기 한 것처럼 타입스크립트의 타입이 열려 있다는 것을 생각해본다면 타당한 에러다.

const v = { x: 1, y: 2, z: 3, name: 'hi, h i~' }
calcLengthV1(v) // name의 값이 NaN이라서 결과가 NaN으로 뜰 수 있다.

따라서, 위의 코드는 타입스크립트 상에서 이렇게 바뀌어야 정확하게 값을 낼 수 있다.

function calcLengthV2(v: Vector3D) {
  return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z)
}

이런 구조적 타이핑은 클래스에서도 재밌는 현상(?) 을 만들어 낸다.

class MadMonster {
  tan: string
  constructor(tan: string) {
    this.tan = tan
  }
}

const hi1 = new MadMonster('hello') // 원래 내가 의도한 코드
const hi2: MadMonster = { tan: 'hello' } // ?!?!

hi2MadMonster로 할당이 가능한걸까? MadMonster에는 tan이라는 string 속성이 있다. 추가로, constructor (Object.prototype에서 온) 를 가지고 있는데, 이는 tan이라는 인수를 받기 때문에 구조적으로 일치한다. 그렇다. 구조적 타이핑의 결과 인 것이다.

구조적 타이핑이 꼭 이렇게 거지같기만(?) 한 것은 아니다. 테스트 할 때는 유용하게 사용할 수 있다.

interface Employee {
  name: string
  id: number
}

function getEmployee(db: DB): Employee[] {
  const rows = db.runQuery('SELECT name, id from EMPLOYEES')
  return rows.map((row) => ({ name: row[0], id: row[1] }))
}

DB를 테스트하는 코드를 만든다고 가정했을 때, 구조적 타이핑을 활용하면 아래와 같은 방법으로 테스트 할 수 있다.

interface Employee {
  name: string
  id: number
}

interface DB {
  runQuery: (sql: string) => any[]
}

function getEmployee(db: DB): Employee[] {
  const rows = db.runQuery('SELECT name, id from EMPLOYEES')
  return rows.map((row) => ({ name: row[0], id: row[1] }))
}

getEmployee에는 runQuery가 존재하는 DB 어댑터를 넣어주면 된다. 위 코드는 프로덕션/테스트를 가지리 않고 잘 동작할 것이다. 구조적 타이핑을 활용하여, 굳이 실제 DB를 구현하지 않아도 테스트 코드를 짤 수 있게 되었다. 굳이 DB를 mocking할 필요가 없다. 추상화한 DB 덕분에, 우리의 로직을 테스트와 프로덕션 상에서 모두 안전하게 작동시킬 수 있다.

결론

  • 자바스크립트는 덕타이핑 특성을 가지고 있고, 타입스크립트는 구조적 타이핑을 활용하여 이 모델을 구현해 냈다고 볼 수 있다.
  • 인터페이스에 할당 되는 값은, 형식적으로 선언되어 있는 속성 이상의 속성을 추가로 가질 수 있다. 즉 열려 있다.
  • 클래스 또한 구조적 타이핑을 따르고 있으므로 조심해야 한다.
  • 구조적 타이핑은 유닛 테스트 시에 유용하다.