이펙티브 타입스크립트 - 02 (1)

2장. 타입스크립트의 타입 시스템

타입스크립트는 코드를 자바스크립트로 변환하는 역할도 하지만 타입시스템이 가장 중요한 역할을 하고 있다.

이번 장에서는 타입 시스템이 무엇인지, 어떻게 사용하는지, 무엇을 결정해야 하는지, 사용하지 말아야 할 기능은 무엇인지 알아보자!

📋 Item 6. 편집기를 사용하여 타입 시스템 탐색하기

타입스크립트를 설치하면, 다음 두가지를 실행할 수 있다.

  • 타입스크립트 컴파일러(tsc)
  • 단독으로 실행할 수 있는 타입스크립트 서버(tsserver)

타입스크립트 컴파일러를 실행하는 것이 주된 목적이지만, 타입스크립트 서버 또한 언어 서비스를 제공한다는 점에서 중요하다.

언어 서비스에는 자동완성, 명세 검사, 검색, 리팩터링 등이 포함된다.

실제로 VScode에서 확인해보면 아래와 같이 타입을 추론하고 오류를 잡아주는 것을 확인할 수 있다.

// 1. num에 마우스를 올려놓으면 num심벌의 추론된 타입이 number임을 확인할 수 있다.
let num = 10 // let num: number;
 
// 2. 함수 add에 마우스를 올려놓으면 함수의 타입을 추론할 수 있다.
function add(a: number, b: number) {
  // function(a: number, b: number): number
  return a + b
}
 
// 3. 조건문외부에서 message타입은 string | null이지만 조건문 내부에서는 string타입으로 추론한다.
function logMessage(message: string | null) {
  if (message) {
    message // (parameter) message: string
  }
}
 
// 4. 객체에서는 개별 각각의 속성을 추론하고 있다.
const foo = {
  x: [1, 2, 3], // (property) x: number[]
  bar: {
    name: 'Fred',
  },
}
 
// 5. 오류를 잡아준다.
function getElement(elOrId: string | HTMLElement | null): HTMLElement {
  if (typeof elOrId === 'object') {
    return elOrId
    // Error: HTMLElement | null형식은 HTMLElement형식에 할당할 수 없다.
    // js에서 typeof null === "object"이기때문에 elOrId는 if문안에서 null일 가능성이 있다.
  } else if (elOrId === null) {
    return document.body
  } else {
    const el = document.getElementById(elOrId)
    return el
    // Error: HTMLElement | null형식은 HTMLElement형식에 할당할 수 없다.
  }
}

📕 요약

  • 편집기에서 타입스크립트 언어 서비스를 적극적으로 활용하자.

  • 편집기를 사용하면 어떻게 타입 시스템이 동작하는지, 타입스크립트가 어떻게 타입을 추론하는지 개념을 잡을 수 있다.

  • 타입스크립트가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득하자.

📋 Item 7. 타입이 값들의 집합이라고 생각하기

타입스크립트에서 가장작은 집합은 아무 값도 포함하지 않는 공집합 이며 타입스크립트에서 never 타입이다.

never타입으로 선언된 변수의 범위는 공집합이기 때문에 아무런 값도 할당할 수 없다.

const x: never = 12
// Error: 12형식은 never형식에 할당할 수 없다.

그 다음으로 작은 집합은 한 가지 값만 포함하는 리터럴(literal) 타입이다.

type A = 'A'
type B = 'B'
type Twelve = 12

유니온(union)타입을 사용하면 두 개 혹은 세 개 이상을 묶을 수 있다.

유니온(union)타입은 값 집합들의 합집합을 말한다.

type AB = 'A' | 'B'
type AB12 = 'A' | 'B' | 12
 
const a: AB = 'A' // OK: 'A'는 집합 {'A', 'B'}의 원소이다.
const c: AB = 'C' // Error: 'C'형식은 'AB'형식에 할당할 수 없다.
 
// OK: {'A', 'B'}는 {'A', 'B'}의 부분집합이다.
const ab: AB = Math.random() < 0.5 ? 'A' : 'B'
 
// OK: {'A', 'B'}는 {'A', 'B', 12}의 부분집합이다.
const ab12: AB12 = ab
 
declare let twelve: AB12
const back: AB = twelve // Error: AB12 형식은 AB형식에 할당할 수 없다.

이렇게 집합의 관점에서, 타입 체커의 주요 역할은 하나의 집합이 다른 집합의 부분집합인지 검사하는 것이라고 볼 수 있다.

이해를 돕기 위해 집합을 타입이라고 생각해보자.

interface Person {
  name: string
}
interface Lifespan {
  birth: Date
  death?: Date
}
 
type PersonSpan = Person & Lifespan
 
// 정상
const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date(),
  death: new Date(),
}

&연산자는 두 타입의 인터섹션(intersection, 교집합)을 계산한다.

위의예제의 경우 PersonLifespan인터페이스는 공통으로 가지는 속성이 없기 때문에 PersonSpan타입을 공집합으로 예상하기 쉽지만, 타입연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용된다.

그래서 PersonLifespan을 둘 다 가지는 값은 인터섹션 타입에 속하게 된다.

당연히 앞의 3가지 속성보다 더 많은 속성을 가지는 값도 PersonSpan타입에 속하게 된다.

const obj = {
  name: 'park',
  birth: new Date(),
  death: new Date(),
  email: 'park@gmail.com',
}
 
// OK: PersonSpan타입에 email이 추가되었는데 PersonSpan타입에 값을 할당할 수 있다.
const v: PersonSpan = obj

인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적인 규칙이다.

그리고, 일반적으로 PersonSpan타입을 선언하는 방법은 아래와 같이 extends키워드를 사용하는 것을 권장한다.

extends키워드를 사용하면 부분집합의 의미로 받아들일 수 있다.

interface PersonSpan extends Person {
  birth: Date
  death?: Date
}

extends키워드는 제너릭 타입에서 한정자로도 쓰이기도 한다.

function getKey<K extends string> (val: any, key: K) { ... }
 
getKey({}, 'x');                                 // OK: 'x'는 string의 부분집합
getKey({}, Math.random() < 0.5 ? 'a' : 'b');     // OK: 'a' | 'b' 는 string의 부분집합
getKey({}, document.title);                      // OK: string은 string의 부분집합
getKey({}, 12);            // Error: 12형식의 인수는 string형식의 매개변수에 할당될 수 없다.

📕 요약

  • 타입을 값의 집합으로 생각하면 이해하기 편하다.

  • 타입스크립트 타입은 엄격한 상속관계가 아니라 집합으로 표현된다.

  • 한객체의 추가적인 속성이 타입선언에 언급되지 않더라도 그 타입에 속할 수 있다.

📋 Item 8. 타입 공간과 값 공간의 심벌 구분하기

타입스크립트의 심벌은 타입 공간이나 값 공간 중의 한 곳에 존재한다.

심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타 낼 수 있기 떄문에 혼란스러울 수도 있다.

아래 예제로 살펴보자.

// 타입으로 사용
interface Cylinder {
  radius: number
  height: number
}
 
// 값으로 사용
const Cylinder = (radius: number, height: number) => ({ radius, height })

위에서 Cylinder는 타입으로도 사용되고 값으로도 사용하고 있지만 서로 아무런 관련도 없다.

상황에 따라서 타입으로 쓰일수도 있고, 값으로 쓰일 수도 있다. 하지만 이러한 점이 오류를 불러오는 경우도 있다.

function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape.radius
    // Error: {}형식에 radius속성이 없다.
  }
}

위의 예제 코드에서는 instanceof를 이용해 shape가 Cylinder타입인지 체크하려고 하고 있다.

그러나 instanceof는 런타임 연산자이고, 값에 대해서 연산을 하기 때문에, instanceof Cylinder는 타입이 아니라 함수를 참조하기 떄문에 에러가 발생하는 것이다.

일반적으로 type이나 interface다음에 나오는 심벌은 타입이고, constlet선언에 쓰이는 것은 값으로 볼 수 있다.

아래 예제로 타입스크립트로 작성된 코드가 자바스크립트로 컴파일 되는 과정에서 어떤 변화가 일어나는지 알아보자.

// 타입스크립트 코드
type T1 = 'string literal'
type T2 = 123
 
const v1 = 'string literal'
const v2 = 123
 
// 컴파일된 자바스크립트 코드
;('use strict')
const v1 = 'string literal'
const v2 = 123

타입스크립트에서 작성한 T1, T2타입 정보는 컴파일 과정에서 제거 된 것을 확인할 수 있다.

이렇게 컴파일과정에서 심벌이 사라지게 된다면 그건 타입이라고 볼 수 있다.

타입과, 값을 구분 할 수 있어야 하지만, class의 경우 타입과 값 두가지로 사용될 수 있다.

class Cylinder {
  radius = 1
  height = 1
}
 
function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape // OK: Type: Cylinder
    shape.radius // OK: Type: number
  }
}

첫 번째 예제에서는 Cylinder가 타입으로 사용되었을 때는 에러가 발생했다.

하지만 Cylinderclass로 사용되었을 때에는 정상적으로 타입을 인식하게 된다.

이렇게 class는 타입과 값 두가지 모두 가능한 예약어이다.

📕 요약

  • 타입스크립트 코드를 읽을 떄 타입인지 값인지 구분하는 방법을 터득하자.

  • 모든 값은 타입을 가지지만, 타입은 값을 가지지 않는다.

  • classenum같은 키워드는 타입과 값 두가지로 사용될 수 있다.

  • "foo"는 문자열 리터럴이거나, 문자열 리터럴 타입일 수 있다. 차이점을 알고 구별하는 방법을 터득하자.

📋 Item 9. 타입 단언보다는 타입 선언을 사용하기

타입스크립트에서 변수에 값을 할당하고 타입을 부여하는 방법은 두가지가 있다.

interface Person {
  name: string
}
 
const alice: Person = { name: 'Alice' } // Type: Person(타입 선언)
const bob = { name: 'Bob' } as Person // Type: Person(타입 단언)
  • 타입선언: 그 값이 선언된 타입임을 명시한다.
  • 타입단언: 타입스크립트가 추론한 값이 존재하더라도 Person타입으로 간주한다.

타입단언보다 타입선언을 사용해야 이유는 아래 코드에서 확인해보자.

// Error: 'name' 속성이 '{}' 형식에 없지만 'Person' 형식에서 필수입니다
const alice: Person = {}
// OK
const bob = {} as Person

타입선언은 할당되는 값이 해당 인터페이스(타입)를 만족하는지 검사하기 때문에, 위의 예제에서 에러가 출력되는 것을 확인할 수 있다.

타입단언은 강제로 타입을 지정했으니 타입체커에서 오류를 무시하라고 하는 것이다.

타입단언이 꼭 필요한 경우가 아니라면, 타입선언을 사용하도록 하자..

언제 타입단언이 필요할까?..

타입단언은 타입체커가 추론한 타입보다 우리가 판단하는 타입이 더 정확할 때 의미가 있다.

예를들어, DOM엘리먼트에 대해서는 타입스크립트보다 우리가 더 정확히 알고 있을 것이다.

document.querySelector('#myButton').addEventListener('click', (e) => {
  e.currentTarget // Type: EventTarget
  const button = e.currentTarget as HTMLButtonElement
  button // Type: HTMLButtonElement
})

타입스크립트는 DOM에 접근할 수 없기 떄문에 #myButton이 버튼 엘리먼트인지 알지 못한다.

그리고 이벤트의 currentTarget이 같은 버튼이어야 하는 것도 알지 못한다..

우리는 여기서 타입스크립트가 알지 못하는 정보를 가지고 있기 때문에 타입 단언문을 사용하는 것이 타당하다.

📕 요약

  • 타입단언(as Type)보다 타입선언(: Type)을 사용하자!

  • 화살표 함수의 반환타입을 명시하는 방법을 알고 있자.

  • 타입스크립트보다 타입 정보를 더 잘 알고 있는 상황에서는 타입단언문을 사용하자!

📋 Item 10. 객체 래퍼 타입 피하기

자바스크립트는 객체 이외에도 기본형 값들에 대한 7가지 타입 (string, number, boolean, null, undefined, symbol, bigint)가 존재한다.

기본형들은 불변이며 메서드를 가지지 않는다는 점에서 객체와 구분되지만, 기본형인 string의 경우 메서드를 가지고 있는 것처럼 보인다.

'primitive'.charAt(3) // m

여기서 charAt는 string의 메서드가 아니고, string을 사용할 때 자바스크립트 내부적으로 많은 동작이 일어난다.

자바스크립트에서 string 기본형에는 메서드가 없지만, 메서드를 가지는 String 객체 타입이 정의 되어 있다.

여기서 자바스크립트는 기본형과 객체 타입을 서로 자유롭게 변환하는데, string기본형에서 charAt메서드를 사용하면, string기본형을 String객체로 래핑하고, 매서드를 호출하고, 마지막에 래핑한 객체를 버리는 동작을 수행한다.

string과 마찬가지로 다른 기본형 타입에도 객체 래퍼 타입이 존재한다.

  • string 과 String
  • number 와 Number
  • boolean 과 Boolean
  • symbol 과 Symbol
  • bigint 와 BigInt

특히, string을 사용할 때는 유의해야 하는데 아래 예시 코드로 알아보자!

function getStringLen(foo: String) {
  return foo.length
}
 
getStringLen('hello') // 정상
getStringLen(new String('hello')) // 정상
function isGreeting(phrase: String) {
  return ['hello', 'good day'].includes(phrase)
  // Error: 'String' 형식의 인수는 'string' 형식의 매개 변수에 할당될 수 없습니다.
}

위의 예제코드로 알 수 있듯이 string은 String에 할당할 수 있지만, String은 string에 할당할 수 없는 것을 확인할 수 있다.

기본형 타입을 객체 래퍼에 할당하는 구문은 오해하기 쉽고 그렇게 사용할 이유도 없다..

또한 대부분의 라이브러리와 타입스크립트가 제공하는 타입선언은 전부 기본형 타입으로 되어 있기 때문에 기본형 타입을 사용하도록 하자.

📕 요약

  • 기본형 값에 메서드를 제공하기 위해 객체 래퍼 타입이 어떻게 쓰이는지 이해하고, 직접 사용하거나 인스턴스를 생성하는것은 피하자!

  • 타입스크립트 객체 래퍼 타입은 지양하고, 기본형 타입을 사용하자!

📋 Item 11. 잉여 속성 체크의 한계 인지하기

타입이 명시된 변수에 객체 리터럴을 할당할 때, 타입스크립트는 해당 타입의 속성이 있는지 그리고 그 외의 속성은 없는지 확인한다.

타입스크립트는 객체 타입을 선언할 때 좀 더 엄밀한 타입체크를 하게 된다.

아래 예시를 확인해보자.

interface Room {
  numDoors: number
  ceilingHeightFt: number
}
 
const r: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',
  // Error: 'Room'형식에 'elephant'가 존재 하지 않는다.
}

위의 코드를 확인해보면 Room타입에 elephant속성이 있는게 어색하긴하지만, 구조적 타이핑 관점에서보면 오류가 발생하지 않아야 한다...

하지만, 에러가 출력 되는 것을 확인할 수 있다.

이번에는 아래와 같이 바로 변수 r에 객체를 대입하는것이 아니라 새로운 변수 obj에 대입하고 변수 obj를 변수r에 대입해보자!

// OK
const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',
}
const r: Room = obj

에러가 발생하지 않는다...

첫 번째 코드에서 에러가 발생하는 이유는 TypeScript에서 인터페이스Room와 객체r 사이의 타입이 불일치 하기 때문이다.

interface Room {}numDoorsceilingHeightFt 두 개의 속성을 정의하고 있지만, 객체 relephant라는 추가적인 속성을 가지고 있어서 타입이 일치 하지 않는 에러가 발생한다.

두 번째 코드에서 에러가 발생하지 않는 이유는 TypeScript에서 덕 타이핑, 구조적 타이핑이라는 개념 때문이다.

TypeScript는 객체의 형식을 해당 객체가 필요한 속성을 포함하고 있는 경우에만 체크한다.

obj 객체는 numDoorsceilingHeightFt 속성을 가지고 있으며 Room 인터페이스가 이러한 속성을 요구하므로 형식이 일치한다. elephant 속성은 인터페이스에 정의되어 있지 않지만, TypeScript에서는 이를 무시한다.

이러한 특성 때문에 두 번째 코드에서는 에러가 발생하지 않고, 객체를 Room 형식으로 할당할 수 있다. 그러나 실제로는 elephant 속성을 무시하는 것이기 때문에 주의해서 코드를 작성해야 할 것 같다.

📕 요약

  • 객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행된다.

  • 잉여 속성 체크는 오류를 찾는 효과적인 방법이지만, 타입스크립트 타임체커가 수행하면 일반적인 구조적 할당 가능성 체크와 역할이 다르다. 개념을 정확히 알아야 잉여속성체크와 일반적인 구조적 할당 가능성 체크를 구분할 수 있다.

  • 잉여 속성 체크에는 한계가 있다. 임시 변수를 도입하면 잉여 속성 체크를 건너 뛸 수 있다.

📋 Item 12. 함수 표현식에 타입 적용하기

자바스크립트(타입스크립트)에서는 함수 문장과 함수 표현식을 다르게 인식 한다..

function rollDice1(sides: number): number {
  /* ... */
} // 문장
const rollDice2 = function (sides: number): number {
  /* ... */
} // 표현식
const rollDice3 = (sides: number): number => {
  /* ... */
} // 표현식

함수의 매개변수, 반환값 전체를 함수 타입으로 선언하여 함수 표현식에 재사용 할 수 있는 장점이 있기 때문에 타입스크립트에서는 아래와 같이 함수 표현식을 사용하는 것이 좋다.

type DiceRollFn = (sides: number) => number
const rollDice: DiceRollFn = (sides) => {
  /* ... */
}

이러한 함수 타입의 선언은 불필요한 코드의 반복을 줄여 주는데, 아래 예제를 통해 알아보자.

// bad
function add(a: number, b: number) {
  return a + b
}
function sub(a: number, b: number) {
  return a - b
}
function mul(a: number, b: number) {
  return a * b
}
function div(a: number, b: number) {
  return a / b
}
 
// good
type BinaryFn = (a: number, b: number) => number
const add: BinaryFn = (a, b) => a + b
const sub: BinaryFn = (a, b) => a - b
const mul: BinaryFn = (a, b) => a * b
const div: BinaryFn = (a, b) => a / b

📕 요약

  • 매개변수나 반환값에 타입을 명시하기보다는 함수 표현식 전체에 타입구문을 적용하는 것이 좋다.

  • 같은 타입 시그니처를 반복적으로 작성한 코드가 있다면 함수 타입을 분리해 내거나, 이미 존재하는 타입을 찾아보자.

  • 다른함수의 시그니처를 참조하려면 typeof fn을 사용하면 된다.

profile

Park Cheol Hwi

Copyright ©2024 kcjfgnl9205 All rights reserved.