이펙티브 타입스크립트 스터디 - 1장

김아현·2023년 7월 5일

TypeScript

목록 보기
4/5
post-thumbnail

시작하며:

프론트의 꽃은 타입스크립트다

프론트엔드를 공부하면서 항상 저 말을 들어오기도 했고, 혼자서 공부하면 흐지부지 책을 다 읽지 못하고 끝내는 경우도 많아서 이번 YAPP사람들에게서 열정을 받아 이펙티브 타입스크립트 한 권은 반드시 완독하기로 했다!

그리고, 우리 팀은 프로젝트 개발을 빠르게 시작했기에 동시에 질문이나 사례를 주고 받으며 성장할거라 예상해 이펙티브 타입스크립트 스터디에 지원해 공부하기로 마음먹었다!

스터디에 도움이 될만한 것

바로 아래 주소는 type-challenges 라는 깃 레포지토리인데, TypeScript를 이용해 여러 까다로운 문제들이 주어지고, 해당 문제들을 해결해가면서 스스로 공부한 타입 선언이나 메소드를 점검할 수 있어요. 참고하시면 좋을 거 같아요!

type-challenges
: 백준처럼 challenge들을 통해 omit, pick, union등 입맛에 맞게 type을 요리하는 능력을 키울 수 있습니다

TS Playground
: TS compile 파일을 vsc로 만들어서 쓰기 귀찮을 때, 온라인 상에서 바로 사용 가능한 온라인 에디터


챕터 1: 타입스크립트 알아보기

아이템 1: 타입스크립트와 자바스크립트의 관계 이해하기

[아이템 요약]

  • 모든 js 프로그램은 ts 프로그램이지만 ts는 별도의 문법을 가지기에 역은 성립하지 않는다.
  • ts는 js 런타임 동작을 모델링해 오류 코드를 찾아내려한다.
  • 타입 체커를 통과해도 런타임 오류가 발생하는 코드가 충분히 존재할 수 있다
  • 문법 상 엄격함의 차이로 js에서는 허용되지만 ts에서는 허용되지 않는 경우가 있다.

모든 js 프로그램이 ts라는 명제는 참이지만 역은 성립하지 않는다.

  • 문법적으로도 자바스크립트의 상위 집합이다. 만약 js 프로그램에 문법적 오류가 없다면, 이는 유효한 ts 프로그램이라 할 수 있다.
// 유효함
function greet(who: string){
	console.log('Hello',who);
} 
// 오류 출력
function greet(who: string){
//             ~~~ ^ 
// Syntax Error: Unexpected token;

위 코드는 ts에서는 유효하지만, js에선 유효하지 않다. : string이 ts에서만 쓰이는 타입 구문이기 때문이다.

타입체커의 타입 추론

let city = 'new york city';
console.log(city.toUppercase());
let city = 'new york city';
console.log(city.toUppercase());
              // ~~~~~ Property 'toUppercase' does not exist on type 'string'.
              //       Did you mean 'toUpperCase'?

1번과 2번은 어떤 차이가 있을까?

1번은 js로 실행한 예이고 2번은 ts로 실행한 예이다. 1번은 아무런 에러를 잡아내지 못하지만 2번 예시에서 ts는 변수의 타입을 알려주지 않아도 초기값으로 타입을 추론한다. 그렇기 때문에 city가 string 타입인 걸 알고, 해당 타입안에 toUppercase가 없음을 바로 체크하여 개발자가 쉽게 문제점을 알아차릴 수 있다.

즉, 타입스크립트를 통해 코드의 ‘의도’가 무엇인지 알려줄 수 있다.

엄격하게 타입 체커 쓰기

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'?
}

타입스크립트 없이도 유효한 코드이지만, for문 내의 state.capitol은 개발자가 의도한 코드가 아니므로 ts 타입체커가 오류를 찾아준다. 하지만, ts는 capitol이 올바른지 capitol이 올바른지 차이를 알지 못한다.

→ 해결을 위해선, 명시적으로 states 를 선언하여 엄격하게 타입체크 해보자.

interface State {
  name: string;
  capital: string;
}
const states: State[] = [
  {name: 'Alabama', capitol: 'Montgomery'},
                 // ~~~~~~~~~~~~~~~~~~~~~
  {name: 'Alaska',  capitol: 'Juneau'},
                 // ~~~~~~~~~~~~~~~~~
  {name: 'Arizona', capitol: 'Phoenix'},
                 // ~~~~~~~~~~~~~~~~~~ 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);
}

위 코드에서 interface State 를 추가해 ts가 잠재적으로 발생할 수 있는 문제점도 찾을 수 있게 해준다.

타입 체커에서 발생하는 오류들

ts의 타입 시스템의 기본 원칙은 ‘자바스크립트의 런타임 동작을 모델링하는 것’이다.

때문에, 몇몇 프로그램들은 다른 언어들과 다르게 작동한다. js 프로그램은 암묵적으로 타입이 변환되어, 아래와 같은 코드도 정상적으로 작동한다. 실행 결과, x와 y는 문자열 ‘23’이 된다.

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

js 엔진의 특성으로 런타임 동작 과정에서 오류가 나지 않는 코드도 ts 타입 체커가 오류를 잡아준다.

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

타입체커가 프로그램의 타입을 체크해주더라도 여전히 오류가 발생할 수 있다.

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

//~~~~~ TypeError: Cannot read property 'toUpperCase' of undefined

이 예시에서 ts는 배열이 인덱스 내에서 사용될 것이라 가정했지만 실제로는 그 범위를 벗어나 오류가 발생한다.

아이템 2: 타입스크립트 설정 이해하기

[아이템 2 요약]

  • ts 컴파일러는 ‘noImplicitAny’나 ‘strictNullChecks’와 같이 언어의 핵심 요소에 영향을 미치는 설정을 포함한다.
  • ts 설정은 tsconfig.json을 사용하자
  • js 프로젝트를 ts로 마이그레이션하는 경우가 아니라면 noImplicitAny 를 설정하자
  • “undefined는 객체가 아닙니다”같은 런타임 오류를 방지하기 위해 strictNullChecks 를 설정하자
  • ts의 strict설정을 하면 대부분의 오류를 잡아낼 수 있다.

ts는 타입 정보를 가질때 가장 효과적이다. 더 분명한 타입으로 오류도 방지하고 가독성을 증가시키며, 생산성을 향상시켜보자.

ts 사용시 ‘implicit any(암시적 any)’ 타입 간주를 방지하기 위해 noImplicitAny 를 설정한다.

$ tsc --noImplicitAny program.ts
// tsConfig: {"noImplicitAny":true,"strictNullChecks":true}

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

만약 프로그램 내에서 ‘null’과 ‘undefined’를 허용하고싶다면 프로그램에서 해당 값이 어디서 오는지 흐름을 파악하고 있어야 한다. 특히, 프로젝트 중에 “undefined는 객체가 아닙니다.” 오류를 마주하기 쉬우므로, 컴파일러 설정에서 Null check 옵션을 잘 체크하자!

  1. strictNullChecks : true 를 쓰려면 null 혹은 undefined를 체크하는 코드를 추가해야 한다.
// tsConfig: {"noImplicitAny":true,"strictNullChecks":true}

const x: number | null = null;

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
  1. strictNullChecks : false 를 써서 모든 타입에서 ‘null’과 ‘undefined’가 사용되지 않도록 방지하자.
// tsConfig: {"noImplicitAny":true,"strictNullChecks":false}

const x: number = null;  // OK, null is a valid number
// tsConfig: {"noImplicitAny":true,"strictNullChecks":true}

const x: number = null;
//    ~ Type 'null' is not assignable to type 'number'

아이템 3: 코드 생성과 타입이 관계없음을 이해하기

[아이템 3 요약]

  • 코드 생성은 타입 시스템과 무관하다. ts 타입은 런타임의 동작이나 성능에 영향을 주지 않는다.
  • 타입 오류가 존재하더라도 코드 컴파일은 가능하다.
  • 타입은 런타임에 쓸 수 없다. 런타임에 타입을 지정하려면, 타입 정보 유지를 위한 별도의 방법이 필요하며 일반적으로는 태그된 유니온과 속성 체크 방법을 사용한다. 추가로 클래스도 있다.

타입스크립트 컴파일러의 역할

  • 최신 ts/js를 브라우저에서 동작할 수 있도록 구버전 js로 transpile
  • 코드의 타입 오류를 체크

→ 이때, 두 기능은 독립적이어서 변환이나 실행 시 코드 내의 타입에 영향을 주지 않는다


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

앞서 컴파일과 타입체크는 독립적이라는 사실을 알았다. 따라서 타입 오류가 있는 파일도 컴파일이 가능하다.

C나 자바는 컴파일과 타입체크가 동시에 이루어지지만, ts는 그렇지 않다. ts는 C나 자바의 warning과 같이 문제가 될만한 부분을 경고하지만 빌드를 멈추지 않는다.

만약 빌드되지 않는 ts 프로그램이 있다면 이는 유효하지 않은 자바스크립트라는 뜻이고 이는 “타입 체크에 문제가 있다”라고 말하는게 더 정확한 표현이라 한다.

오류가 있을 때, 컴파일하지 않으려면 tsconfig.json에 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은 타입이기 때문에 런타임 시점에서 아무런 역할을 할 수 없다. 실제 ts → js 컴파일 과정에서 모든 인터페이스, 타입, 타입 구문은 제거된다.

따라서 코드에서 다루는 shape 타입을 명확하게 하고 오류를 잡으려면, 런타임에도 타입 정보를 유지하는 방법이 필요하다.

이 방법에는 여러가지가 있다.

  1. type shape의 속성이 존재하는지 체크하기

    • if ('height' in shape) → shape의 property인 ‘height’가 존재하는지 체크한다.
    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;
      }
    }
  2. 런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 태그 기법 사용하기

    • interface내에 kind 라는 속성을 태그로 써서 타입 정보를 저장한다.
    • kind의 타입은 cosnt tag
    interface Square {
      kind: 'square';
      width: number;
    }
    interface Rectangle {
      **kind: 'rectangle'**; // const 선언
      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;
      }
    }
  3. 타입을 클래스로 만들기

    • Square와 Rectangel을 클래스로 만들어서
    • 인터페이스는 타입으로만 선언 가능하지만, 클래스는 타입과 값으로 모두 사용할 수 있다.
      • type Shape = Squre | Rectangle 부분에서 Rectangle 클래스는 타입으로 참조되고 shape instance of Rectangle 부분에서는 값으로 참조된다.
    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
      }
    }

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

타입 연산은 컴파일 이후 제거된다. 때문에 런타임에 영향을 주지 못한다.

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

위 코드를 살펴보자. 위 코드에서 ‘as number’ 타입 연산 부분은 런타임에 작동하지 않기 때문에, val을 number 타입으로 정제할 수 없다. 따라서 타입을 정제하기 위해 아래와 같이 수정해보자.

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

즉, 런타임에선 타입을 지정할 수 없으므로, 타입 정보 유지를 위한 별도의 방법을 생각하고 코드를 작성하자.


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

  1. 선언된 타입이 변경되는 예
function turnLightOn() {}
function turnLightOff() {}
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.`);
  }
}
  1. 선언된 타입이 API로 인해 달라지는 예
interface LightApiResponse {
  lightSwitchValue: boolean;
} 
async function setLight() {
  const response = await fetch('/light');
  const result: LightApiResponse = await response.json();
  setLightSwitch(result.lightSwitchValue);
}

1번 예시에서 3번째 줄의 : boolean 은 타입 선언문이며 이 구문은 ts 구문이기 때문에 런타임에서 제거된다. 때문에 js에서 setLightSwitch에 매개로 문자열 “on”을 넘겨주면 case에 타입 체크를 통과해 마지막 default 콘솔이 실행될 수 있다.

이렇게 타입체크를 했음에도 ts구문이 런타임에 제거되는 특성에 따라 개발자가 의도한 방향과 다르게 작동할 수 있다. 따라서, 선언된 타입이 언제든 달라질 수 있음을 염두에 두고 코드를 작성해야 한다.


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

c++과 같은 객체지향 프로그래밍 언어들은 ‘함수 오버로딩’을 지원하지만, ts는 타입과 런타임 동작이 별개이기 때문에 불가능하다.

즉, 하나의 함수에 대해 여러 선언문을 작성할 순 있지만 구현체 자체는 오직 하나이다.

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

// 하나의 add함수를 타입별로 선언
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

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

앞서 알아본 내용들처럼, ts의 타입과 타입연산은 js 변환 시점에서 제거되어 런타임 성능에 아무런 영향을 미치지 않는다. 대신 몇 가지 주의할 사항이 있다.

  • ts 컴파일러는 ‘런타임’ 오버헤드 대신 ‘빌드타임’ 오버헤드가 있다. ts의 컴파일은 일반적으로는 상당히 빠른 편이며 오버헤드가 커졌을 때 transpile only 옵션으로 타입 체크를 건너뛸 수 있다.

아이템 4: 구조적 타이핑에 익숙해지기

[아이템 4 요약]

  • js는 덕타이핑 기반이고, ts가 이를 모델링하기 위해 ‘구조적 타이핑’을 사용한다. 인터페이스에 할당 가능한 값이라면 타입에 명시적으로 나열된 속성을 갖는다.
  • ts 설정은 tsconfig.json을 사용하자
  • js 프로젝트를 ts로 마이그레이션하는 경우가 아니라면 noImplicitAny 를 설정하자
  • “undefined는 객체가 아닙니다”같은 런타임 오류를 방지하기 위해 strictNullChecks 를 설정하자
  • ts의 strict설정을 하면 대부분의 오류를 잡아낼 수 있다.

js는 본질적으로 ‘duck typing’ 기반이다. 때문에 타입 스크립트는 아래 코드를 충분히 이해한다.

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

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

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

const v: NamedVector = { x:3, y:4, name:'Zee'};
calculateLength(v); // 정상, 결과는 5

위 코드는 NamedVector와 Vector2D 사이 아무런 관계 선언이 없음에도 동작한다. 또, NamedVector를 위한 별도의 연산을 구현하지 않았다. 덕타이핑에 의해 두 인터페이스의 구조가 호환될 때 오류가 생기지 않는다.

구조적타이핑(덕타이핑)의 장점도 있지만 문제가 발생하는 경우도 있다.

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

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

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

3D 벡터를 만들때 normalize 함수를 만들어 벡터 길이를 1로 만들고자 한다면.. 이 함수는 1보다 조금 더 긴 결과를 출력하게 된다

이 이뉴는 vector3D가 구조적 타이핑의 관점에서 Vector2D와 호환되기 때문이다. 따라서, 이 오류는 처리되지 않고 타입 체커에서도 문제로 인식하지 못한다.

ts 타입 시스템에서 타입은 ‘open’ 열려있기 때문에 확장 가능성을 갖는다.


profile
멘티를 넘어 멘토가 되는 그날까지 파이팅

0개의 댓글