What is Typescript? (effective typescript Chap.1)

What is Typescript?

(참고 : https://www.typescriptlang.org/docs/handbook/2/basic-types.html)

동적 타입 언어, 자바스크립트

자바스크립트는 인터프리터 언어이면서 동적 타입 언어로, 런타임 환경에서 한줄한줄씩 코드를 읽고 실행하며 값이 할당되는 과정에서 값의 타입에 의해 변수의 타입이 결정된다. 런타임에서 코드가 실행될 때 해당하는 값이 어떤 동작과 능력을 가지고 있는지를 확인하고, 불가능하다면 TypeError를 반환한다.

예시에서는 message가 Hello World라는 문자열 값이지만, 중간에 숫자나 boolean 등의 값으로 재할당이 일어나면서 변수의 타입 또한 변경될 수 있다. 두번째/세번째 줄의 실행 결과는 해당 코드가 실행되는 당시의 message의 값에 따라 달라지게 되며 message가 호출 가능한지, toLowerCase라는 프로퍼티를 가지는지(string 타입인지), 만약 가진다면, toLowerCase를 호출 가능한지 등을 평가하고 실행이 완료될 것이다.

let message = "Hello World!";
message.toLowerCase();
message(); // TypeError: message is not a function

실행 시점에서 string과 number과 같은 원시 타입은 typeof 연산자를 통해 각 값들의 타입을 확인할 수 있지만, 함수의 파라미터와 같은 경우에는 그 타입을 장담할 수 없다. 아래 예시 함수에서는 x가 호출 가능한 프로퍼티인 flip을 가져야만 정상적으로 동작한다. 그러나 함수가 실제로 호출되는 그 순간의 x값과 타입에 따라 정상작동 여부가 평가되기 때문에 코드를 실행 전에 동작을 예측하기 어렵다.

function fn(x) {
  return x.flip();
}

공식문서의 내용을 그대로 옮겨보자면, 타입이란 어떤 값이 fn으로 전달될 수 있고, 어떤 값은 실행에 실패할 것임을 설명하는 개념이다. JavaScript는 오직 동적 타입만을 제공하며, 코드를 실행해야만 어떤 일이 벌어지는지 비로소 확인할 수 있다.

A type is the concept of describing which values can be passed to fn and which will crash. JavaScript only truly provides dynamic typing - running the code to see what happens.

타입스크립트

위의 문제를 해결하기 위해, 코드를 실행하기 전에 이러한 버그를 미리 발견할 수 있는 정적 타입 검사기 TypeScript가 도입되었다. 정적 타입 시스템은 사용된 값들의 형태와 동작을 정하고, 이 정보들을 기반으로 프로그램이 제대로 작동할지에 대하여 알려준다. 아래 코드를 (자바스크립트로 실행하기 전에) 타입스크립트로 실행하면 런타임에 발생했을 에러를 런타임 이전에 확인할 수 있다.

const message = "hello!";
message(); // This expression is not callable. Type 'String' has no call signatures.

또한, 타입스크립트는 자바스크립트 실행 시 런타임 에러는 발생하지 않지만 무언가 이상한 경우도 사용자에게 알려주어 휴먼에러를 방지(!)하기도 한다. JS에서는 객체에 존재하지 않는 프로퍼티에 접근하면 에러를 반환하는 것이 아니라 undefined를 반환하지만, 타입스크립트의 정적 타입 시스템은 (오류를 발생시키지 않는 “유효한” JS 코드라도) 오류로 간주할 수 있다.

// 미선언 속성
const user = {
  name: "Daniel",
  age: 26,
};
user.location; // Property 'location' does not exist on type '{ name: string; age: number; }'.

// 오타
const announcement = "Hello World!";
announcement.toLocaleLowercase(); // Error
announcement.toLocalLowerCase(); // Error
announcement.toLocaleLowerCase();

// 미호출 함수 : ()로 호출하는 것을 잊었을 때
function flipCoin() {
  return Math.random < 0.5; // Operator '<' cannot be applied to types '() => number' and 'number'.
}

// 논리 오류 : 실행되지 않는 블록
const value = Math.random() < 0.5 ? "a" : "b";
if (value !== "a") {
  // ...
} else if (value === "b") { 
  // This condition will always return 'false' since the types '"a"' and '"b"' have no overlap.
  // ...
}

실제 타입스크립트는 크게 ts파일을 js파일로 컴파일(번역의 의미를 강조하여 트랜스파일이라고도 한다)하는 기능과, 이제까지 우리가 이야기했던 타입 시스템 기능을 가진다. 타입스크립트와 자바스크립트의 관계성, 타입스크립트의 기본적인 특성을 조금 더 자세하게 살펴보도록 하자!

Item1. Relation between Typescript and Javascript

타입스크립트는 타입이 정의된 자바스크립트의 상위집합(superset)이다.

문법적으로 오류가 없는 자바스크립트 프로그램은 타입스크립트 프로그램이라고 할 수 있다. 단순히 main.js 파일을 main.ts 파일로 바꾸어도 정상적으로 동작한다. 그러나 모든 타입스크립트는 자바스크립트가 아닌데, 타입스크립트 파일(ts, tsx)을 컴파일하면 타입과 관련된 구문들이 모두 삭제되고 자바스크립트 파일(js, jsx)이 생성되는 것을 알고있다면 이해가 편할 것이다.

아래는 유효한 타입스크립트 프로그램이지만, 노드로 바로 실행하면 unexoected token ":이 있다는 오류를 출력한다.

function greet(who: string) {
  console.log('Hello', who)
}

해당 파일을 컴파일하면 타입과 관련된 부분은 모두 삭제되고 아래와 같은 자바스크립트 프로그램으로 변환된다. (아래 코드로 작성된 자바스크립트, 타입스크립트 파일 모두 유효하다.)

function greet(who) {
  console.log('Hello', who)
}

타입 체커를 통해 문제를 찾아낼 수 있다.

타입 추론을 통한 문법적 오류 탐색

문법적 유효성과 동작의 유효성은 다른 문제이지만, 많은 자바스크립트의 런타임 문제가 타입 체커만으로도 개선될 수 있다.(약 15%의 문제가 개선된다는 설문조사가 있다고 한다) 아래 코드는 JS로 실행하는 경우 런타임에서 "city.toUppercase is not a function"이라는 타입에러가 발생한다. toUpperCase 함수의 오타이지만 실제 해당 코드가 실행될 때에나 오류를 알 수 있다. 반면 타입스크립트의 타입체커는 city 변수의 값을 통해 타입을 추론하여 "toUppercase 속성이 string속성에 없습니다. toUpperCase를 사용하시겠습니까"라고 런타임 이전에 알려준다.

let city = 'new york city';
console.log(city.toUppercase()); // 오타 -> toUpperCase()

의도와는 다르게 작성된 잘못된 코드 탐색

실제 오류는 발생하지 않지만 의도와는 다르게 작성된 코드 또한 존재한다. 아래 예시는 name, capital 속성을 가지고 있는 state에서 오타로 capitol 속성을 부르는 경우이다. 실제 코드를 실행하면 에러 없이 undefined를 세번 프린트하게 되지만, 런타임 이전에 타입 체커는 "capitol 속성이 없으며 capital을 사용할 것이냐"라고 물어봐주어 개발자의 의도와 실제 코드 동작이 달라지는 경우를 방지해준다.

const states = [
  { name: 'Alabama', capital: 'Montgomery' },
  { name: 'Alaska', capital: 'Juneau' },
  { name: 'Arizona', capital: 'Phoenix' },
]
for (const state of states) {
  console.log(state.capitol)
  // ~~~~~~~ Property 'capitol' does not exist on type
  //         '{ name: string; capital: string; }'.
  //         Did you mean 'capital'?
}

의도를 분명하게 나타내기 위한 명시적 타입 선언 가능

아래 코드는 위의 코드와 똑같아보일 수 있으나, states의 정의에서 오타가 생긴 경우이다. 타입 체커는 capital이 옳은 속성 명인지 capitol이 옳은 속성 명인지 모른다. 따라서 State의 타입 선언이 없었다면 state의 타입을 name과 capitol이라는 속성을 가진 객체로 추론하여 console.log 부분에서 "capital 속성을 capitol로 바꾸시겠습니까?" 하고 물어봤을 것이다. 반면 State 타입 선언이 존재하는 경우, states를 정의하는 과정에서 의도에 맞는 코드인지 묻는 아래와 같은 제안을 확인하게 된다.

interface State {
  name: string
  capital: string
}
const states: State[] = [
  { name: 'Alabama', capitol: 'Montgomery' },
  // ~~~~~~~~~~~~~~~~~~~~~
  { name: 'Alaska', capitol: 'Juneau' },
  // ~~~~~~~~~~~~~~~~~
  { name: 'Arizona', capitol: 'Phoenix' },
  // State 정의가 있을 때에 의도를 명확하게 전달할 수 있다.
  // ~~~~~~~~~~~~~~~~~~ Object literal may only specify known
  //         properties, but 'capitol' does not exist in type
  //         'State'.  Did you mean to write 'capital'?
  // ...
]
for (const state of states) {
  console.log(state.capital)
  // State 정의가 없었다면??
  // ~~~~~~~ Property 'capital' does not exist on type
  //         '{ name: string; capitol: string; }'.
  //         Did you mean 'capitol'?
}

관계성 한눈에 보기

타입스크립트는 자바스크립트에서 발생하는 런타임오류를 줄이기 위해 도입되었으며,크게 컴파일 기능 + 타입 체크 기능으로 구성된다. 아래 그림에서 자바스크립트와의 관계성을 한번에 확인할 수 있다. 타입과 관련된 구문들이 존재하지 않는 타입스크립트만 자바스크립트라 할 수 있으며(컴파일 전후 동일), 모든 타입스크립트/자바스크립트가 타입 체크를 통과하지는 않는다. (사용자가 capital을 선언하고 capitol을 부르는 코드를 의도했을 수도 있지 않은가!)

타입 스크립트는 자바스크립트를 모델링한다

타입스크립트는 자바스크립트 기반으로 제작되었기 때문에, 아래와 같이 기본적인 타입 변환과 같은 특징들을 그대로 계승한다.

const x = 2 + '3' // OK, type is string
const y = '2' + 3 // OK, type is string

단, (위에서 봤던 정의되지 않은 속성에 접근을 막는 것과 같이) 의도치 않은 코드가 런타임오류로 이어질 수 있어 자바스크립트에서는 정상적으로 동작하는 코드에도 타입 에러를 표시하기도 한다. 책의 저자는 아래와 같이 null과 7을 더하거나 불필요한 매개변수를 추가해서 함수 호출을 하는 것을 당연하게 여긴다면 타입스크립트를 쓰지 말라고 한다.(돌려까는 기술인가..!ㅋㅋ)

const a = null + 7 // Evaluates to 7 in JS
// ~~~~ Operator '+' cannot be applied to types ...
const b = [] + 12 // Evaluates to '12' in JS
// ~~~~~~~ Operator '+' cannot be applied to types ...
alert('Hello', 'TypeScript') // alerts "Hello"
// ~~~~~~~~~~~~ Expected 0-1 arguments, but got 2

모든 런타임 오류를 잡아낼 수 있지는 않다

타입스크립트는 타입시스템을 기반으로 설계되었기 때문에, 아래와 같이 타입 체크를 통과하더라도 여전히 런타임에서 오류가 발생할 수 있다. 타입 체커를 통과했더라도 에러가 발생하는 경우는 any를 사용할 수록 빈번하게 발생하는데, 이는 item5에서 더욱 자세하게 이야기하고 있다.

const names = ['Alice', 'Bob']
console.log(names[2].toUpperCase()) // TypeError: Cannot read property 'toUpperCase' of undefined

Item2. typescript settings

타입스크립트 설치 후 tsc --init 실행 시, 설정 파일은 자동으로 생성된다. 대부분의 설정들은 어디서 소스파일을 찾을지 어떤 종류의 출력을 생성할지 등을 제어하는 데에 그치지만, 일부 설정은 타입스크립트 언어 자체의 핵심 요소들을 제어하기도 한다.

noImplicitAny

noImplicityAny는 변수의 타입 추론이 any로 되는 것을 방지한다. 아래 코드는 해당 설정이 false로 되어있을 때에는 문제가 없다. 그러나 noImplicitAny 설정이 true로 되어있을 때에는 "Parameter '~' implicitly has an 'any' type"라는 에러가 발생한다.

function add(a, b) {
  return a + b
}
add(10, null)

타입이 추론된 값이 any가 되었을 때에만 오류이므로 아래처럼 타입을 명시한다면 해당 오류를 해결할 수 있다. 단, any를 매개변수에 사용하게 되면 타입체커는 제 기능을 제대로 할 수 없다. (item6에서 다시 말하겠지만 자동완성이나 변수명 바꾸기 등에서도 제외된다 ㅠㅠ)

function add(a: any, b: any) {
  return a + b
}
function sub(a: number, b: number) {
  return a - b
}

타입스크립트는 타입 정보를 가질 때에 가장 효과적이기 때문에 되도록이면 noImplicityAny를 true로 설정하고 변수에 any가 아닌 타입을 명시하는 것을 습관화해아한다. 해당 설정은 왜 있는지 의문을 가질 수도 있는데, 윗윗 예시에서와 같이 타입 정보가 아예 없으면 js 코드와 동일하다는 것을 깨달을 수 있다. 이미 JS로 작성된 프로젝트에 TS를 마이그레이션할 때를 제외하고는 noImplicitAny false를 사용하지 않도록 하자!

strictNullChecks

const x: number = null
// tsConfig: {"noImplicitAny":true,"strictNullChecks":true}일 때에만
//    ~ Type 'null' is not assignable to type 'number'

strictNullChecks는 null과 undefined를 모든 타입에서 허용할지를 설정한다. 단 해당 설정은 noImplicitAny가 true로 설정되었을 때에만 작동한다.(하긴 아니라면 이곳저곳에서 any로 타입이 추론되어 null 체크가 무의미할 듯 하다) 만약 의도적으로 null이나 undefined를 허용하려면 const x: number | null = null;로서 설정할 수 있다. 해당 변수가 null일 가능성이 있을 때에 속성에 접근하기 위해서는 if로 null이 아님을 확인하거나 !를 통해 null이 아니라고 assert할 수 있다.

const el = document.getElementById('status')
el.textContent = 'Ready'
// ~~ Object is possibly 'null'

if (el) {
  el.textContent = 'Ready' // OK, null has been excluded
}
el!.textContent = 'Ready' // OK, we've asserted that el is non-null

Item3. Understand That Code Generation Is Independent of Types

타입스크립트 컴파일러는 크게 두가지 역할을 한다.

  1. 최신 타입스크립트/자바스크립트를 부라우저에서 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일 (default ES3, target 변경 가능)
  2. 코드의 타입오류 체크

이 두가지는 서로 독립적이다. 때문에 타입 오류가 있어도 자바스크립트로의 컴파일에는 문제가 없고, 타입 체크에 통과한다 하더라도 자바스크립트 실행 시 런타임 에러가 발생할 수 있다.

타입 오류가 있는 코드도 컴파일 가능

let x = 'hello'
x = 1234

위의 코드에서 x는 'hello'를 기반으로 스트링 타입이라고 추론된다. 타입체커는 '1234' 형식은 'string' 형식에 할당할 수 없다는 타입 에러를 띄우지만, tsc를 통해 컴파일하면 정상적으로 js로 변환된다. 타입스크립트 오류는 문제가 될 만한 부분들을 알려 주지만, 빌드를 멈추지는 않는다.

책의 저자는 웹 애플리케이션을 만들면서 일정 부분에 타입 에러가 발생해도 여전히 타입스크립트는 컴파일된 산출물을 생성하기 때문에, 문제가 된 오류를 수정하지 않아도 애플리케이션의 다른 부분을 테스트할 수 있다는 장점을 언급했다. 이런 타입오류가 발생했을 때에 컴파일을 막기 위해서 noEmitOnError 설정을 변경할 수 있다!

런타임에는 타입 체크가 불가능하다

"~~"는 형식만 참조하지만, 여기서는 값으로 사용되고 있습니다."라는 에러를 몇번 접해본 적이 있어, 이마를 탁 치게 되었다.

interface Square {
  width: number
}
interface Rectangle extends Square {
  height: number
}
type Shape = Square | Rectangle

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
    // ~~~~~~~~~ 'Rectangle' only refers to a type,
    //           but is being used as a value here
    return shape.width * shape.height
    //         ~~~~~~ Property 'height' does not exist
    //                on type 'Shape'
  } else {
    return shape.width * shape.width
  }
}

instanceof 체크는 런타임에 일어나지만, Rectangle과 같은 인터페이스, 타입, 타입 구문은 자바스크립트로 컴파일되는 과정에서 제거된다. 따라서 런타임에서까지 타입 정보를 유지하기 위한 방법이 필요한데, 보통 아래의 세가지 방법을 주로 사용한다.

  1. 속성 체크
    속성 체크는 런타임에 접근 가능한 값에만 관련되지만, 타입체커 또한 shape의 타입을 Rectangle로 보정해주기 때문에 에러가 사라진다고 한다.
interface Square {
  width: number
}
interface Rectangle extends Square {
  height: number
}
type Shape = Square | Rectangle
function calculateArea(shape: Shape) {
  if ('height' in shape) {
    shape // Type is Rectangle
    return shape.width * shape.height
  } else {
    shape // Type is Square
    return shape.width * shape.width
  }
}
  1. 태그 속성 사용
    1번 방법과 비슷한 방법으로, 타입 정보를 런타임에도 접근 가능한 '태그'로서 명시적으로 저장하는 방법이 있다. 런타임에도 타입 정보를 유지할 수 있기 때문에 자주 사용되는 기법이라고 한다.
interface Square {
  kind: 'square'
  width: number
}
interface Rectangle {
  kind: 'rectangle'
  height: number
  width: number
}
type Shape = Square | Rectangle

function calculateArea(shape: Shape) {
  if (shape.kind === 'rectangle') {
    shape // Type is Rectangle
    return shape.width * shape.height
  } else {
    shape // Type is Square
    return shape.width * shape.width
  }
}
  1. 타입과 값 모두 사용할 수 있는 클래스형 사용하기
    타입은 컴파일에 사라지기 때문에 런타임에 접근 가능한 값을 사용하는 방식이다. 아래와 같이 타입을 클래스로 만들면 된다! type Shape = Square | Rectangle에서는 Rectangle이 타입으로 참조되지만, shape instanceof Rectangle에서는 값으로 참조된다. 이 내용은 item8에서 조금 더 자세하게 다룬다.
class Square {
  constructor(public width: number) {}
}
class Rectangle extends Square {
  constructor(public width: number, public height: number) {
    super(width)
  }
}
type Shape = Square | Rectangle

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
    shape // Type is Rectangle
    return shape.width * shape.height
  } else {
    shape // Type is Square
    return shape.width * shape.width // OK
  }
}

타입 연산은 런타임에 영향을 주지 않는다

아래 예시의 컴파일 전후 코드를 살펴보면 바로 이해할 수 있다. string 또는 number 타입인 값을 항상 number로 정제하려 할 때, 1번 코드는 타입 체커에서는 number로 가정되고 오류 없이 통과하지만, 이것이 변환된 2번의 자바스크립트 코드에서는 정제 과정이 없다. 실제 동작을 위해서는 3번 코드처럼 자바스크립트 런타임 타입을 체크하고 변환을 수행해야 한다.

// 1. 컴파일 전
function asNumber(val: number | string): number {
  return val as number;
}

// 2. 컴파일 후
function asNumber(val) {
  return val;
}

// 3. TOBE
function asNumber(val: number | string): number {
  return typeof val === 'string' ? Number(val) : val;
}

런타임 타입과 선언된 타입은 다를 수 있다

아래 코드에서 value는 boolean으로 선언되어 default 부분은 실행되지 않아야 한다. 따라서 타입스크립트는 접근할 수 없는 코드라는 에러를 찾아내야 하는데, 실제로는 (strict모드로 설정하더라도) 그렇지 않다. 자바스크립트 코드로 컴파일되면 타입선언값이 지워지고, API의 반환값으로 setLightSwitch 코드를 실행했을 때에 value가 문자열값이 되는 등의 경우가 있을 수 있기 때문이다.

function setLightSwitch(value: boolean) {
  switch (value) {
    case true:
      turnLightOn()
      break
    case false:
      turnLightOff()
      break
    default:
      console.log(`I'm afraid I can't do that.`)
  }
}

타입스크립트 타입으로는 함수를 오버로드할 수 없다.

C++과 같은 언어는 동일한 이름에 매개변수만 다른 여러 버전의 함수를 허용한다. 타입스크립트에서는 타입과 런타임 동작이 무관하기 때문에, 아래와 같은 함수 오버로딩은 불가능하다. (실제 컴파일된 코드를 예상하면 이해가 쉽겠지만, 똑같은 코드가 단순히 두번 반복된 코드일 뿐이다)

function add(a: number, b: number) {
	  // ~~~ Duplicate function implementation
  return a + b
}
function add(a: string, b: string) {
	  // ~~~ Duplicate function implementation
  return a + b
}

아래와 같이 타입 수준에서만 오버로딩을 지원하고 타입체커를 정상적으로 통과하지만, 해당 부분은 컴파일되면 없어진다는 것을 유념해야 한다. 실제 함수의 구현은 하나뿐이다.

function add(a: number, b: number): number
function add(a: string, b: string): string

// 실제 구현체
function add(a, b) {
  return a + b
}

const three = add(1, 2) // Type is number
const twelve = add('1', '2') // Type is string

타입스크립트 타입은 런타임 성능에 영향을 주지 않는다

계속 반복해왔듯이, 타입과 타입 연산자는 자바스크립트로 컴파일될 때 모두 제거되어 런타임에는 (당연히 성능을 포함하여) 아무런 영향을 주지 못한다. 타입스크립트 컴파일에서도 성능을 위한 몇가지 커스터마이징을 제공하기도 한다. 빌드도구에서 transplie only 설정을 통해 타입체크를 건너뛰고 컴파일만 실행할 수도 있으며, tsc --target 옵션을 통해 컴파일 타겟 버전을 설정하여 호환성과 성능 정도를 조절할 수 있다.

Item4. Get Comfortable with Structural Typing

One of TypeScript’s core principles is that type checking focuses on the shape that values have. This is sometimes called “duck typing” or “structural typing”.

타입스크립트 공식문서에는 “타입스크립트의 코어 원리 중 하나는 타입 체킹을 형태(Shape)에 중점을 두며, 이것을 "덕 타이핑" 또는 "구조적 서브타이핑" 이라고 부른다” 라고 말하고 있다. JS는 덕 타이핑 기반으로, 함수의 매개변수 값이 제대로 주어진다면 그 값이 어떻게 만들어졌는지/어떤 타입인지 신경쓰지 않는다. TS 또한 이런 기본 코어 동작들을 모델링하여 비슷하게 작동한다. "객체 적합성"이 객체의 실제 타입이 아니라 특정 메소드와 속성이 존재하는지에 의해 결정된다. ~~ 타입이 정해지면 타입의 속성들이 정해지는 것이 아니라, 타입의 속성들이 정해지면 타입이 정해진다고 생각하면 조금 더 이해가 쉬울까? 모 블로그에서는 타입 검사 측면과 다형성 측면의 관점을 분리해서 생각하라고 첨언한다 ~~

interface Vector2D {
  x: number
  y: number
}
function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y)
}
interface NamedVector {
  name: string
  x: number
  y: number
}
const v: NamedVector = { x: 3, y: 4, name: 'Zee' }
calculateLength(v) // OK, result is 5

위의 코드를 보면 바로 이해가 갈 것이다. v는 NamedVector지만 Vector2D가 가지는 number타입의 x와 y속성을 가지고 있기 때문에, calculateLength 함수 호출이 가능하다. 여기서 중요한 점은, Vector2D와 NamedVector 사이의 관계를 따로 선언하거나, 네임드벡터를 위한 별도의 calculateLength 함수를 구현하지 않아도 정상적으로 타입체크가 일어난다는 점이다. 객체 적합성(또는 호환성)을 위해서는 타입 선언에 나열된 속성들을 전부 가지고 있기만 하면 되고, 더 많이 가지고 있어도 상관없다!

구조적 타이핑이 문제를 일으키기도 한다

함수를 작성할 때 호출에 사용하는 매개변수의 속성들이 매개변수의 타입에 선언된 속성만을 가질 것이라고 생각하기 쉬운데, 이로 인해서 오류 및 의도와는 다르게 작동하는 경우가 발생하기도 한다.

함수의 구조적 타이핑

interface Vector2D {
  x: number
  y: number
}
interface Vector3D {
  x: number
  y: number
  z: number
}
function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y)
}
function normalize(v: Vector3D) {
  const length = calculateLength(v)
  return {
    x: v.x / length,
    y: v.y / length,
    z: v.z / length,
  }
}
normalize({x:3, y:4, z:5}) // {x:0.6, y:0.8, z:1}, 길이가 1이 넘는다

위와 같이 Vector3D의 크기를 1로 맞추기 위해 normalize라는 함수를 만들었다고 하자.
calculateLength는 x와 y값만 고려하기 때문에 이때 x가 3, y가 4, z가 5인 3D벡터를 정규화하면 1보다 길이가 더 긴 벡터를 아웃풋으로 갖게 된다. 타입스크립트가 구조적 타이핑을 지원하지 않았다면 calculateLength 호출 시 에러를 띄웠겠지만, 구조적 타이핑을 지원하기 때문에 Vector3D가 vector2D와 호환되면서 에러 없이 z가 길이 계산에서 무시된 것이다. 사실 이 부분을 보면서 그냥 단순한 휴먼에러가 아닌가? 싶었지만 타입스크립트 설정 변경을 통해 이러한 문제를 해결할 수 있다고 한다..!!

의도와는 다르게 동작하는 예시가 하나 더 있다. 위의 케이스에서 caculateLength의 문제점을 깨달은 사람이 Vector3D인 v의 속성들을 모두 돌면서 length를 계산하도록 코드를 고쳤다고 하자. 아래 for 루프에서 axis들은 분명 x, y, z 중에 하나이고 coord는 숫자가 되어 오류 없이 코드가 작동해야 하는데, 타입스크립트는 "string은 Vector3D의 인덱스를 사용할 수 없고 coord는 암시적으로 any타입이 된다"는 오류를 낸다.

interface Vector3D {
  x: number
  y: number
  z: number
}
function calculateLengthL1(v: Vector3D) {
  let length = 0
  for (const axis of Object.keys(v)) {
    const coord = v[axis]
    // ~~~~~~~ Element implicitly has an 'any' type because ...
    //         'string' can't be used to index type 'Vector3D'
    length += Math.abs(coord)
  }
  return length
}

이는 함수를 작성할 때 구조적 타이핑을 잊고 Vector3D의 속성을 제한해서 생각했기 때문에 나타나는 실수이다. const vec3D = { x: 3, y: 4, z: 1, address: '123 Broadway' }를 예시로 들면 이해가 쉬운데, 이때 calculateLengthL1(vec3D)를 실행하면 NaN을 리턴한다. 구조적 타이핑으로 인해 v는 어떤 속성이든 가질 수 있고, axis는 "x"|"y"|"z" 타입이 아니라 string 타입을, coord는 any타입을 갖게된다. 이런 경우는 루프를 사용하기보다는 앞에서와 같이 속성들을 각각 더하는 구현이 옳다.

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

클래스의 구조적 타이핑

구조적 타이핑은 함수 뿐만 아니라 클래스 관련 할당시에도 일어난다.

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

const c = new C('instance of C')
const d: C = { foo: 'object literal' } // OK!

위 코드에서 c는 C가 값으로 사용되었고 d에서는 C가 타입으로 사용되었는데, d는 정상적으로 C타입에 할당된다. C가 가지고있는 모든 속성을 포함하고 있기 때문에 구조적 타이핑에 따라 d는 C타입과 호환될 수 있는 것이다.

아래 코드는 인프런의 타입스크립트 스터디에서 해당 내용을 정리한 것이다.

class C {
  foo: string
  constructor(foo: string) {
    this.foo = foo
  }
  method() {}
}
class E {
  method() {}
}
class D extends E {
  foo: string
  constructor(foo: string) {
    super()
    this.foo = foo
  }
}

const c = new C('instance of C')
const d: C = { foo: 'object literal' } // error. 'method' 속성이 '{ foo: string; }' 형식에 없지만 'C' 형식에서 필수입니다.
const e: C = { foo: '', method() {} } // foo, method 속성이 모두 있으면 okay.

const f: C = new D('') // prototype chain 상에 method가 존재하면 okay.
const g = Object.create({ method() {} }, { foo: { value: '' } }) // g: any
const h: C = g // C type 강제(assert)하여 okay.
const i: { foo: string; method: () => void } = Object.create({ method() {} }, { foo: { value: '' } })
const j: C = i // { foo, method } 타입을 강제하여 okay.

구조적 타이핑은 유닛테스트에도 도움이 된다

테스트를 작성할 때에도 구조적 타이핑을 통해 쉽게 처리할 수 있다. 데이터베이스에 쿼리하고 결과를 처리하는 getAuthors가 다음과 같다고 하자. DB가 어떻게 이루어져있는지 명확하게 선언하지 않고 runQuery 메소드를 정의한것만으로도, DB 데이터의 타입을 선언하고 mocking할 수 있다.

참고로, 모킹이란 데이터베이스 또는 외부 API에 의존하는 코드를 테스트해야 할 때에,
실제 데이터베이스와 연동하거나 실제 외부 API를 호출하는 대신
외부에 의존하는 부분을 임의의 가짜로 대체하는 테스팅 기법이다.

타입스크립트는 만약 우리가 명시적으로 인터페이스들을 자세히 서술했다면 DB가 인터페이스를 충족하는지를 확인했을 것이다. 그러나 우리의 테스트코드는 실제 DB에 대한 정보나 해당 라이브러리가 불필요하다. 구조적 타이핑의 추상화를 통해서, 우리는 DB의 구현과 테스트로직을 분리할 수 있다. (물론 라이브러리간의 의존성 또한 분리할 수 있다! item 52를 확인하는 그날까지!)

interface Author {
  first: string
  last: string
}
interface DB {
  runQuery: (sql: string) => any[]
}
function getAuthors(database: DB): Author[] {
  const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`)
  return authorRows.map(row => ({ first: row[0], last: row[1] }))
}
test('getAuthors', () => {
  const authors = getAuthors({
    runQuery(sql: string) {
      return [
        ['Toni', 'Morrison'],
        ['Maya', 'Angelou'],
      ]
    },
  })
  expect(authors).toEqual([
    { first: 'Toni', last: 'Morrison' },
    { first: 'Maya', last: 'Angelou' },
  ])
})

Item5. Limit Use of the any Type

타입스크립트의 타입 시스템은 점진적이고 선택적이다. 코드에 타입을 조금씩 추가할 수도 있고, 언제든지 타입 체커를 해제할 수도 있다. 이 기능들은 any 타입을 통해 가능하다. 그러나 any는 타입스크립트의 장점들을 사용하지 못하게 하고, 위험성 또한 가지고 있다. 타입스크립트에 익숙하지 않다면 any 타입이나 as any와 같은 단언문을 사용하고 싶고 남용하게 되겠지만, 이들의 위험성과 불편함을 살펴보도록 하자.

타입 불안정성

let age: number
age = '12'
// ~~~ Type '"12"' is not assignable to type 'number'
age = '12' as any // OK

타입 체커는 선언에 따라 age를 number로 판단한다. age를 any로 단언한다면 지금 당장 타입 체커는 통과할 수 있다. 그러나 이후 런타임age += 1를 실행했을 때에 age의 값이 의도와는 다르게 “121”라는 문자열을 갖게 될것이다.

타입 시그니처(타입 선언) 무시

함수를 작성할 때는 (noImplicitAny 설정에 따라) function contract-이하 시그니처-를 명시해야 한다. 호출하는 쪽은 약속된 타입의 입력을 제공하고, 함수는 약속된 타입의 출력을 반환한다. 그러나 any 타입을 사용하면 이런 약속을 어길 수 있다.

function calculateAge(birthDate: Date): number {
  return 0
}
let birthDate: any = '1990-01-19'
calculateAge(birthDate) // OK

앞의 예시에서 birthDate 매개변수는 string이 아닌 Date타입이어야 한다고 약속했다. 그러나 any 타입을 사용하면 calculateAge의 시그니처를 무시할 수 있다. 자바스크립트에서는 암시적으로 타입이 변환되기 때문에 이런 경우 특히 문제가 될 수 있다.

언어 서비스 미제공

아래와 같이 자동완성 기능이나 이름 변경 기능 또한 사용할 수 없다!

코드 리팩토링 시, 버그를 유발한다.

interface ComponentProps {
  onSelectItem: (item: any) => void
}
function renderSelector(props: ComponentProps) {
  /* ... */
}

let selectedId: number = 0
function handleSelectItem(item: any) {
  selectedId = item.id
}

renderSelector({ onSelectItem: handleSelectItem })

any 타입을 사용하면 버그 발견 또한 어려워진다. 어떤 아이템을 선택할 수 있는 웹 애플리케이션을 만든다고 위의 코드와 같이 가정해보자. 애플리케이션에는 onSelectItem 콜백이 있는 컴포넌트가 있을 것이다.
만약 onSelectItem함수가 item 객체를 모두 받지 않고 필요한 부분만 전달하도록 컴포넌트를 개선한다고 하자. 여기서는 id만 필요하니, 아래와 같이 제일 우선 상단의 ComponentProps을 변경하게 될 것이다.

interface ComponentProps {
  onSelectItem: (id: number) => void
}
function renderSelector(props: ComponentProps) {
  /* ... */
}

let selectedId: number = 0
function handleSelectItem(item: any) {
  selectedId = item.id
}

renderSelector({ onSelectItem: handleSelectItem })

문제는 여기서 발생한다. ComponentProps만 변경했을 뿐인데 타입 체크를 모두 통과한다. handleSelectItem이 any 매개변수를 받기 때문에, item 매개변수에 item 객체가 아닌 숫자 id를 직접 전달받아도 타입 체크를 통과한다. 물론 편집기에서는 문제가 없다고 나오지만 실제 런타임에서는 item.id 구문을 실행할 때에 number의 id속성을 찾게 되면서 오류가 발생한다.

이 외에도, any 타입은 타입 설계를 불명확하도록 하거나 런타임에 타입 오류를 발견하게 되는 문제를 유발한다.

좋은 웹페이지 즐겨찾기