Effective typescript #1

Bard·2024년 8월 26일
1

effective-typescript

목록 보기
1/1
post-thumbnail

I. 타입스크립트와 자바스크립트의 관계 이해하기

타입스크립트는 문법적으로 자바스크립트의 상위집합이다.
자바스크립트 프로그램에 문법 오류가 없다면, 유효한 타입스크립트 프로그램이라고 할 수 있다.

반대로 타입스크립트는 별도의 문법을 가지고 있기 때문에 일반적인 경우, 유효한 자바스크립트 프로그램이 아니다.

function greet(who: string) {
                  ^
SyntaxError: Unexpected token :

타입스크립트는 자바스크립트 런타임 동작을 모델링하는 타입 시스템을 갖고 있기 때문에 런타임 오류를 발생시키는 코드를 찾아내려고 한다.

const names = [`Alice', 'Bob'];
console.log(names[2].toUpperCase());

// TypeError: Cannot read property 'toUpperCase' of undefined
  • 물론 런타임에서 발생하는 모든 오류를 잡지는 못한다.
    타입스크립트의 타입 시스템은 전반적으로 자바스크립트 동작을 모델링하나, 자바스크립트에서는 허용되지만 타입스크립트에서는 문제가 되는 경우도 있다.
const a = null + 7;
      //  ~~~~ '+' 연산자를 ... 형식에 적용할 수 없습니다.

II. 타입스크립트 설정 이해하기

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

이 코드가 타입 체커를 통과할 수 있을까?
이는 설정이 어떻게 되어있는지 모른다면 대답할 수 없다.

설정은 커맨드 라인에서 사용할 수 있으며

$ tsc --noImplicitAny program.ts

tsconfig.json 파일을 이용해서도 가능하다. 보통 이 방법을 추천한다.

타입스크립트의 설정을 제대로 사용하려면 noImplicitAnystrictNullChecks를 제대로 이해해야 한다.

noImplicitAny

noImplicitAny는 변수들이 미리 정의된 타입을 가져야 하는지 여부를 제어한다.

다음 코드는 noImplicitAny가 설정되어있다면 오류가 된다.

function add(a, b) {
  return a + b;
}

암시적 any를 사용하지 않겠다는 설정이기 때문에 이를 직접 명시적으로 any로 선언해주거다 더 분명한 타입을 사용하여 해결할 수 있다.

function add(a: number, b: number) {
  return a + b;
}

noImplicitAny가 있는 타입스크립트와 없는 타입스크립트는 완전히 다른 언어처럼 느껴질 것이며, 자바스크립트 파일을 타입스크립트로 전환하는 상황 외에는 써주는 것이 좋다.

strictNullChecks

strictNullChecksnullundefined가 모든 타입에서 허용되는지 확인하는 설정이다.

strictNullChecks가 해제되었을 때 다음 코드는 유효한 코드지만, strictNullChecks를 설정하면 오류가 된다.

const x: number = null;
//    ~ 'null' 형식은 'num ber' 형식에 할당할 수 없습니다.

null 대신 undefined를 써도 같은 오류가 나며, null을 허용하려면 다음과 같이 의도를 명시적으로 드러내야 합니다.

const x: number | null = null;

strict

위 두 설정은 타입스크립트에서 가장 중요한 설정이며, 이 모든 체크를 설정하고 싶다면 strict설정을 하면 된다.

타입스크립트에 strict 설정을 하면 대부분의 오류를 잡아낸다.

III. 코드 생성과 타입이 관계없음을 이해하기

타입스크립트는 두 가지 역할을 수행한다.

  • 최신 타입스크립트/자바스크립트를 브라우저에서 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일한다.
  • 코드의 타입 오류를 체크한다.

이 두 가지 역할은 완벽히 독립적이며, 타입스크립트가 자바스크립트로 변환될 때 코드 내의 타입에는 영향을 주지 않는다.

또한 자바스크립트의 실행 시점에도 타입은 영향을 미치지 않는다.

타입 오류가 있는 코드도 컴파일이 가능하다.

$ cat test.ts
let  = 'hello';
x = 1234;
$ tsc test.ts
test.ts:2:1 = error TS2322: `1234` 형식은 `string` 형식에 할당할 수 없습니다.

2 x = 1234;
  ~
  
$ cat test.js
var x = 'hello';
x = 1234;

이 부분 타입스크립트가 엉성한 언어처럼 보일 수 있지만, 여전히 컴파일된 산출물을 생성하기 때문에, 문제가 된 오류를 수정하지 않더라도 애플리케이션의 다른 부분을 테스트할 수 있다.

만약 오류가 있을 때 컴파일하지 않으려면, tsconfig.jsonnoEmitOnError를 설정하면 된다.

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

실제로 자바스크립트로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 그냥 제거되어 버린다.

따라서 다음 코드는 별 의미가 없다. Rectangle은 타입이기 때문에 instanceof가 실행되는 런타임 시점에 아무런 역할을 할 수 없기 때문이다.

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;
  }
}

따라서 다음과 같은 코드로 수정할 수 있다.
첫 번째는 height 속성이 존재하는지 체크해 보는 것이다.

function calculateArea(shape: Shape) {
  if ('height' in shape) {
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}

또는 런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 태그 기법이 있다.

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') {
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}

export default {}

또는 타입을 클래스로 만들 수도 있다.

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) {
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width; // OK
  }
}

export default {}

type Shape = Square | Rectangle; 부분에서 Rectangle은 타입으로 참조되지만, if (shape instanceof Rectangle) 부분에서는 값으로 참조되므로, 오류가 발생하지 않는다.

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

타입 체커를 통과시키기 위해서 다음과 같은 코드를 짤 수 있다.

function asNumber(val: number | string): number {
  return val as number;
}

이 코드를 트랜스파일하면 다음과 같은 코드가 된다.

function asNumber(val) {
  return val;
}

아무 의미가 없다.

원하는 목적을 달성하기 위해서는 아래와 같이 자바스크립트 연산을 통해 변환을 수행해야 한다.

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

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

다음 코드에서 console.log가 실행될 수 있을까?

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.`)
  }
}

: boolean는 런타임에 제거되므로, 실수로 setLightSwitch"ON"으로 호출한다면 console.log가 실행된다.

순수 TS에서도 네트워크 호출로 받아온 값이면 함수를 실행시킬 수 있는데, 예를 들면 다음과 같다.

interface LightApiResponse {
  lightSwitchValue: boolean
}
async function setLight() {
  const response = await fetch('/light')
  const result: LightApiResponse = await response.json()
  setLightSwitch(result.lightSwitchValue)
}

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

일반적으로 다음과 같은 함수 오버로딩은 불가능하다.

C++과 같은 오버로딩을 기대하면 안된다는 것이다.

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

타입스크립트가 함수 오버로딩을 지원하긴 하지만, 온전히 타입수준에서만 동작한다.

따라서 구현체는 오직 하나뿐이어야 한다. (자바스크립트에서 두 구현체를 구분할 수 없으므로)

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

두 선언문은 JS 변환 단계에서 제거되게 된다.

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

타입과 타입 연산자는 JS 변환 시점에 제거되기 때문에 런타임 성능에 영향을 주지는 않는다.

다만 필연적으로 빌드타임 오버헤드가 있을 수밖에 없다.

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

자바스크립트는 본질적으로 Duck typing 기반이고, 타입스크립트 또한 이런 동작을 그대로 모델링 한다.

그러나 타입 체컹의 타입에 대한 이해도가 사람과 조금 다르기 때문에 가끔 예사치 못한 결과가 나온다.

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

아래 코드로 예를 들어보자.

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

이때 벡터의 길이를 계산하는 코드는 다음과 같을 것이다.

function calculateLength(v: Vector2D) {
  return Math.hypot(v.x, v.y);
}

이제 이름이 들어간 벡터를 만들어보자.

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

NamedVectornumber 타입의 xy 속성이 있기 때문에 calculateLength 함수로 호출 가능하며, 다음 코드를 이해할만큼 충분히 영리하다.

const v: NamedVector = {x: 3, y: 4, name: 'Zee'};
calculateLength(v); // OK, result is 5

흥미로운 점은 NamedVectorVector2D의 관계를 전혀 선언하지 않았다는 점이며, NamedVector를 위한 별도의 calculateLength를 구현할 필요가 없다는 것이다.

이는 NamedVector의 구조가 Vector2D와 호환되기 때문이며, 여기에서 구조적 타이핑이라는 용어가 사용된다.

여기에서 타입스크립트의 타입은 열려있다라는 점을 인식하고 가야한다.

이런 특성때문에 다음과 같은 오류가 발생한다.

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) // OK, returns NaN

따라서 정확한 타입으로 객체를 순회하는 것은 까다로운 문제이며, 이 경우에는 아래와 같은 구현이 더 낫다.

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.

dC 타입에 할당되는 것이 어색해보이지만, 구조적으로는 필요한 속성과 생성자를 가진다. (생성자는 Object.prototype에 존재한다.)

따라서 문제가 없으며, 다음 예제를 통해 좀 더 감을 익힐 수 있을 것이다.

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

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.

class E {
  method() {}
}
class D extends E {
  foo: string
  constructor(foo: string) {
    super()
    this.foo = foo
  }
}
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.

구조적 타이핑은 테스트를 작성할 때 특히 유용하다.

데이터베이스에 쿼리를 날리고 결과를 처리하는 함수를 가정해보자.

interface Author {
  first: string
  last: string
}
function getAuthors(database: PostgresDB): Author[] {
  const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`)
  return authorRows.map(row => ({ first: row[0], last: row[1] }))
}

여기에서 getAuthors를 테스트하기 위해서는 mocking한 PostgresDB를 생성해야 한다.

이때 구조적 타이핑을 활용해서 더 구체적인 인터페이스를 정의할 수 있다.

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] }))
}

runQuery 메서드가 있기 때문에, getAuthorsPostgresDB를 사용할 수 있고, 구조적 타이핑 덕분에 PostgresDBDB인터페이스를 구현하는지 명확히 선언할 필요가 없다.

더 간단하게는 아래와 같이 작성할 수도 있다.

test('getAuthors', () => {
  const authors = getAuthors({
    runQuery(sql: string) {
      return [
        ['Toni', 'Morrison'],
        ['Maya', 'Angelou'],
      ]
    },
  })
  expect(authors).toEqual([
    { first: 'Toni', last: 'Morrison' },
    { first: 'Maya', last: 'Angelou' },
  ])
})

이렇게 테스트를 하는 것이 가능함으로써 실제 환경의 데이터베이스에 대한 정보가 불필요하며, 심지어 모킹 라이브러리도 필요없다.

추상화를 함으로써, 로직과 테스트를 특정한 구현으로부터 분리한 것이다.

V. any 타입 지양하기

타입 시스템은 점진적이고, 선택적이며, 이 기능들의 핵심은 any 타입이다.

따라서 가끔은 아래처럼 as any를 이용해서 오류를 해결하고 싶을 때도 있을 것이다.

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

그러나 일부 특별한 경우를 제외하고는 any를 사용하면 타입스크립트의 수많은 장점을 누릴 수 없게 된다.

any 타입의 위험성에 대해서 살펴보자.

any 타입에는 타입 안전성이 없다.

앞선 예제에서 타입체커는 선언에 따라 age를 계속 number로 판단할 것이고, 혼돈은 걷잡을 수 없이 커진다.

age += 1; // 런타임에 정상, age는 "121"

any는 함수 시그니처를 무시한다.

함수를 작성할 때는 호출하는 쪽에서는 약속된 타입의 입력을 제공하고, 함수는 약속된 타입의 출력을 반환해야 한다.

그러나 any를 사용하면 이런 약속을 어길 수 있다.

function calculateAge(birthDate: Date): number {
  // ...
}

let birthDate: any = '1990-01-19'
calculateAge(birthDate) // OK

any 타입에는 언어 서비스가 적용되지 않는다.

어떤 심벌에 타입이 있다면, 타입스크립트는 자동완성 기능과 적절한 도움말을 제공한다.

그러나 any를 사용하면 아무 도움도 받지 못한다.

따라서...

이 외에도

  • any 타입은 코드 리팩터링 때 버그를 감추며,
  • 타입 설계도 감추고,
  • 그 결과 전반적인 타입시스템의 신뢰도를 떨어뜨린다.

따라서 any의 사용은 가급적 지양하는 것이 좋다.

Reference

  • 댄 밴더캄, 『이펙티브 타입스크립트』, 장원호, 서울:도서출판 인사이트, PP.1-32, 2021,
    978-89-6626-334-9
profile
The Wandering Caretaker

0개의 댓글