Typescript

Contents

  • Introduction
  • Preview
  • Why add types?
  • Annotations and Inference

    • Type Annotations
    • Type Inference
  • TypeScript Basics

    • Variables
    • Array
    • Turple
    • Object Types (Interface)
    • Intersection & Union Types
    • Type Systems
    • Function Signature Overloading
  • Interface & Type Aliases

    • Objects and Index Signatures
    • Type Test
  • Classes
  • Converting to Typescript

    • Step 1. Compiling in "loose mode"
  • Generics

    • When to use generics
  • Top & Bottom Types
  • Advanced Types
  • Declaration Merging
  • Patterns

    • Structural Typing
    • Types as Sets of Value
    • Limit Use of the any Type
    • Excess Property Checking의 한계 이해하기
    • Type Operation and Generics to Avoid Repeating Yourself
    • Null 거르기
    • Unions of Interfaces vs Interfaces of Unions
    • Restricting access with interfaces and write reusable code
  • Compiler API
  • References

Introduction

TypeScript는 마이크로소프트에서 개발한 typed, syntactic superset 입니다. 이것이 모든 자바스크립트 언어가 TypeScript compiler를 에러 없이 통과한다는 것을 의미하지는 않습니다.

TypeScript는 코드 수준에서 타입을 체크하며, 런타임 수준에서의 타입 체크는 없습니다. 따라서 런타임에서 데이터를 패치해 오거나 유저로부터 입력된 값을 받을 때 타입 체킹이 없음을 인지해야 합니다. 이는 타입스크립트가 런타임에서는 타입 체킹이 없는 일반적인 자바스크립트로 변환되기 때문입니다. 런타입 수준에서의 타입 체킹을 하려면 그만큼 코드가 더 늘어나야 할 것입니다.

Typescript code를 Typescript complier에 전달하면 에러가 있는지 확인을 한 후 아래의 예와 같이 일반적인 Javascript를 변환합니다.

TypeScript는 크게 세 파트로 구성되어 있습니다. 이 세 가지에는 프로그래밍 언어 자체와 에디터를 사용할 때 여러 정보를 제공해주는 Language Server, 타입 체킹과 TypeScript를 Javascript로 변환해주는 compiler가 있습니다.

Preview

아래의 코드는 todo 아이템을 네트워크 요청 후 로그 하는 코드입니다. 네트워크 요청 후 받는 데이터의 type을 interface를 통해 지정해 주었습니다. 그래서 만약 개발환경에서 todo.ID 또는 todo.Title 등의 오타로 Todo에 접근하면 에디터에서 에러 문구를 확인할 수 있습니다.

만약 타입스크립트를 사용하지 않았다면 오타로 인해 발생한 에러를 코드를 직접 실행하기 전까지는 모르고 지나칠 수 있을 것입니다. 또한, logTodo 함수에서는 argument로 받을 데이터의 타입을 지정할 수 있습니다.

이 또한 잘못된 타입을 logTodo 함수에 넘겨주던가 argument 순서를 잘못 전달하면 타입스크립트를 사용하고 있기 때문에 오류를 미리 발견할 수 있게 해 줍니다.

import axios from 'axios';

const url = 'https://jsonplaceholder.typicode.com/todos/1';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

axios.get<Todo>(url).then(response => {
  const todo = response.data;
  const id = todo.id;
  const title = todo.title;
  const completed = todo.completed;

  logTodo(id, title, completed);
});

const logTodo = (id: number, title: string, completed: boolean) => {
  console.log(`
    The Todo with ID: ${id}
    Has a title of: ${title}
    Is it finished? ${completed}
  `);
};

Why add types?

자바스크립트에 타입을 추가함으로써 여러 이점을 얻을 수 있습니다. 개발자가 자신의 코드에 제약을 둠으로써 코드를 어느 정도 예상할 수 있고, 코드를 처음 접한 동료 개발자가 흐름을 파악하는 데도 도움이 될 수 있습니다. 다른 방법으로는 Defensive programming을 들 수도 있겠지만, 이는 성능이나, 복잡성, 인지적인 간접 비용을 발생시킵니다.

타입을 도입함으로써 스펠링 에러와 같은 일반적인 에러를 잡아낼 수도 있습니다. 여러 곳에서 사용하는 같은 함수에 required argument를 추가하고, 해당 함수를 사용하는 모든 곳을 미처 업데이트하지 못할 수 있는데 이럴 때 에러를 발견하기가 쉽지 않습니다. 또, 타입스크립트를 사용하면 일부 런타임 에러를 컴파일 시 확인할 수 있으며, 개발자 경험을 향상할 수 있습니다.

Annotations and Inference

Type annotation은 특정 variable이 어떠한 type을 가질 수 있는지에 대해 Typescript에 전달하는 코드입니다. Type inference란 Typescript가 자동으로 특정 variable에 대해 어떠한 type을 가질 수 있는지 확인하는 것입니다. 아래에서 개념과 예시를 좀 더 살펴볼게요.

Type Annotations

Annotations with variables

변수의 타입 annotation은 아래와 같이 간단하게 할 수 있습니다. 지정된 타입 외의 다른 타입의 데이터를 재할당하려 하면 에러 메시지를 확인할 수 있습니다.

let counts: number = 7;
let speed: string = 'fast';
let clicked: boolean = false;
let nothing: null = null;
let now: Date = new Date();

Object literal annotations

우선 배열의 type annotation을 살펴보면 위의 변수에서 사용한 방식과 크게 다르지 않습니다. 타입 뒤에 객체 타입인 배열을 추가해 줄 수 있습니다.

const colors: string[] = ['coral', 'green', 'red', 'blue'];
const clicked: boolean[] = [true, false, true];

클래스 인스턴스의 경우에도 타입으로 지정할 수 있습니다.

class Car {
  move() {
    console.log('Move');
  }
}

let car: Car = new Car();

객체의 경우는 아래와 같이 타입을 지정할 수 있습니다. 각 property에 지정된 타입이 아닌 다른 타입의 데이터가 들어오면 에러 메시지를 확인할 수 있습니다. 지정된 property가 아닌 임의의 다른 property를 추가하려 해도 에러 메시지를 확인할 수 있습니다.

let position: { x: number; y: number } = {
  x: 10,
  y: 20
};

Function annotations

함수는 annotation을 지정할 때 함수가 어떤 타입의 값을 argument로 받고, 어떤 타입의 값을 반환할지 지정해 줄 수 있습니다.

아래의 log 함수는 숫자를 argument로 받고 반환하는 값은 undefined입니다. 때문에, 변수 log 옆에 "(num: number) => void"라는 annotation을 추가해 주었습니다. 외관상으로는 함수로 보이지만 함수가 아니고 함수에 대한 설명을 나타냅니다.

const log: (num: number) => void = (num: number) => {
  console.log(num);
};

Function annotation의 경우 우리는 코드로 직접 함수가 어떠한 타입의 argument를 받고, 어떤 타입의 값을 반환하는지 지정할 수 있습니다. Function annotation과는 다르게 Typescript의 Function inference 는 함수가 어떠한 타입을 반환하는지에 대해서만 인지를 합니다.

아래의 function annotation의 예시에서 add 함수는 argument로 숫자를 받고 반환하는 값 또한 숫자를 반환합니다. 만약 함수가 숫자가 아닌 다른 타입의 값을 반환한다면, 에디터에서 에러 메시지를 확인할 수 있습니다.

const add = (a: number, b: number): number => {
  return a + b;
};

function annotation에서 Typescript가 함수가 어떠한 타입을 반환하는지는 알지만, 함수 자체의 로직이 올바른지 아닌지에 대해서는 인지를 하지 않습니다. 단지, annotation에 지정된 타입에 맞는 값을 반환하는지만 인지를 합니다.

함수의 argument의 경우 type inference가 작동하지 않습니다. 따라서, 우리는 항상 type annotation으로 argument의 타입을 명시해야 합니다.

함수의 반환 값에 대해서는 typescript의 type inference를 이용할 수 있습니다. 그런데도, 가능하면 type inference 를 사용하지 않고자 합니다. 함수가 어떠한 값을 반환해야 하지만, 아래와 같이 사람의 실수로 코드로 반환을 하지 않는다면 typescript는 해당 함수의 반환 값을 void로 처리할 것입니다. 따라서, 실수를 줄이고자 함수의 경우 annotation을 사용하고자 합니다.

// 아래의 함수는 반환 타입은 void로 인식됨.
function add(a: number, b: number) {
  a + b;
}

Void and Never

아래 함수의 경우 전달받은 인자를 log 할 뿐 어떠한 값도 반환하지 않습니다. 이려면 반환 값의 타입을 void로 지정해 줍니다. 코드상 null이나 undefined를 반환하는 경우에도 void로 지정할 수는 있습니다.

const logger = (message: string): void => {
  console.log(message);
};

아래의 예시는 함수가 미처 완료되기 전 에러를 throw 합니다. 이러한 경우가 많지는 않지만, 이 경우 never를 type annotation으로 지정할 수 있습니다.

const throwError = (message: string): never => {
  throw new Error(message);
};

Object Annotations

아래의 예시는 날씨 객체가 있고, argument로 전달받은 객체(날씨 정보)를 log 하는 함수가 있습니다. 앞서 function annotation의 방식으로 argument를 destructuring해서 아래와 같이 작성할 수 있을 것입니다.

const logWeather = (({ date, weather }: { date: Date; weather: string }): void => {
  console.log(forecast.date);
  console.log(forecase.weather);
};

Type Inference

앞서 Type Annotation에 대해 데이터 타입별로 확인해 보았습니다. 앞의 예시에서 변수에 할당하는 초기값이 있음에도 불구하고, 예시를 위해 type annotation을 기재했습니다. 하지만, 사실 변수에 할당하는 초기값이 있다면 이를 근거로 Typescript 가 알아서 타입을 선별할 수 있습니다. 위 예시에서 사실상 type annotation을 명시하지 않아도 초깃값이 있기 때문에 같은 타입 체킹을 할 수 있습니다.

그렇다면 어떠한 기준으로 annotation과 inference를 구분해 사용해야 할까요??

일반적으로 가능한 모든 경우에 Type inference를 사용합니다. Type Inference를 사용하지 못하는 몇몇 경우에만 Type annotation을 사용하면 좋습니다. 그러면 type annotation을 사용하는 경우에 대해서 살펴볼게요.

함수가 any 타입을 반환하는 경우

함수가 'any' 타입을 반환할 때 annotation을 사용할 수 있습니다. 예를 들어, JSON.parse를 이용해 json을 파싱하면, input은 String이지만 output으로 문자열, 숫자, 객체 등 여러 타입의 값이 올 수 있습니다. Typescript는 파싱된 값의 타입을 미리 알 수 없기 때문에 이 경우 타입을 any로 지정합니다.

우리가 Typescript를 사용하는 이유는 타입을 도입함으로써 에디터에서 즉각적으로 에러를 발견하기 위해서입니다. 우리가 any 타입을 사용한다면 이러한 의미가 퇴색되기 때문에 되도록 any 타입을 피하는 것이 좋습니다.

// Function returning the 'any' type
const json = '{"x": 10, "y": 20}';
const coordinates = JSON.parse(json);
console.log(coordiantes); // {x: 10, y: 20};

위 경우, any 타입을 피하기 위해 annotaion으로 타입을 지정할 수 있습니다.

const json = '{"x": 10, "y": 20}';
const coordinates: { x: number; y: number } = JSON.parse(json);
console.log(coordinates); // {x: 10, y: 20};

Delayed Initialization

let 변수를 선언하며 즉시 초기값을 할당하지 못 하는 경우에도 annotation으로 미리 타입을 지정해 줄 수 있습니다.

타입을 정상적으로 참조할 수 없는 변수

가능하면 이러한 패턴을 피하는 것이 좋지만, 프로그래밍을 하다 보면 한 변수가 두 가지 타입의 값을 가지게 될 수 있습니다. 아래의 경우 type inference를 정상적으로 사용할 수 없기 때문에 annotation으로 사용할 타입을 지정해 줄 수 있습니다.

const numbers = [-5, -4, 3];
let numberAboveZero: boolean | number = false;

for (let i = 0; i < numbers.length; i++) {
  if (numbers[i] > 0) {
    numberAboveZero = numbers[i];
  }
}

Typescript Basics

Variables

가장 기본적인 변수에 Typescript가 적용된 것을 확인해 보겠습니다. annotation이 없다면 초기에 변수에 할당한 type을 Typescript가 초기 타입으로 인식합니다. 이후 다른 타입을 할당하면 에디터에서 에러를 확인할 수 있습니다. const의 경우 할당된 값을 변경할 수 없습니다.

// (1) 변수 x에 string "hello world"를 할당합니다.
let x = 'hello world';

// (2) 같은 타입의 string을 재할당 할 수 있습니다.
x = 'hello mars';

// (3) 다른 타입인 숫자는 할당할 수 없습니다.
x = 42; // 🚨 ERROR

// (4) const 에 string "hello world"를 할당합니다.
const y = 'hello world';

function foo(arg: 'hello again') {}
foo(y); // 에디터에서 에러 메시지 확인할 수 있습니다.

초기값이 없는 경우에도 annotation을 이용해 아래와 같이 타입을 미리 지정해 줄 수 있습니다.

let apples: number = 7;
let finished: boolean = true;
let nothing: null = null;
let now: Date = new Date();

Array

Typed Array는 일관된 타입의 요소를 가지는 배열을 말합니다. 배열을 생성할 때 요소로 추가할 값이 있으면 해당 값을 배열 안에 추가하면 Typescript가 해당 요소의 타입으로 알아서 배열의 타입을 지정합니다. 만약 초기에 빈 배열을 할당한다면 any type이 지정되기 때문에 annotation으로 요소의 타입을 지정해 주는 것이 좋습니다.

const carMakers: string[] = [];
// or
const carMakers = ['ford', 'chevy', 'hyundai'];

네스팅된 배열도 타입을 지정할 수 있습니다.

const carsByMake = [['f150'], ['cruize'], ['s3']];
  ⬇️
const carsByMake = string[][]: [];

배열에 타입을 추가함으로써 개발할 때 가져올 수 있는 여러 장점이 있습니다. 먼저 배열에서 요소를 취할 때 에디터에서 그 요소의 타입을 확인할 수 있습니다.

// 마우스 포인터를 car위에 올려 놓으면 타입을 확인할 수 있습니다.
const car = carMakers[0];
const myCar = carMakers.pop();

타입이 맞지 않는 요소를 데이터로 추가하려 하면, 에러임를 확인할 수 있습니다. carMakers는 타입이 string 인 요소를 취하기 때문에 아래와 같이 다른 타입의 요소를 추가하려 하면 미리 인지할 수 있습니다.

carMakers.push(123);

추가로 배열 map, forEach와 같은 배열을 사용할 때 요소의 타입을 알기 때문에 에디터에서 해당 요소의 built-in 메소드를 쉽게 확인하고 사용할 수 있습니다.

필요하다면 배열의 타입을 유연하게 지정할 수도 있습니다.

const dates: (Date | string)[] = [];
dates.push('2019-09-08');
dates.push(new Date());

Turple

유연한 타입의 배열과 함께, 타입의 순서 또한 꼭 지켜야 하는 경우가 있습니다. 아래와 같이 객체를 배열의 형태로 변경했을 때, 배열의 요소가 원하는 순서대로 위치해야 개발자가 데이터를 예상할 수 있어 작업을 수월하게 할 수 있습니다. 배열 요소의 순서가 임의로 입력받게 되면 에러가 발생할 수 있는데, 이러한 경우 Turple을 사용할 수도 있습니다.

const cellPhone = {
  storage: '16 GB',
  price: 100,
  used: false
};

const galaxy = ['16 GB', 100, false];
  ⬇️
const galaxy: [string, number, boolean] = ['16 GB', 100, false];
galaxy[0] = 100; // ➡️ 에러 메시지를 확인할 수 있습니다.

위의 코드를 type alias를 사용해 좀 더 직관적으로 변경할 수 있습니다.

type CellPhone = [string, number, boolean];

const galaxy: CellPhone = ['16 GB', 100, false];

Turple은 사실상 배열의 요소만 보고, 그 요소의 데이터가 어떠한 의미를 나타내는지 파악하기가 어렵습니다. 객체에서는 key 값으로 해당 데이터의 의미를 나타낼 수 있기 때문에 일반적으로 turple보다 객체의 데이터를 주로 사용합니다.

Object Types

앞서 Type annotation에 살펴보았지만, 객체에 대한 type annotation은 아래와 같이 작성할 수 있습니다.

let houseInfo: { houseNumber: number; streetName: string };
houseInfo = {
  houseNumber: 33,
  streetName: 'Main St.'
};

뒤에서 Interface에 대해 좀 더 살펴보겠지만, 객체 type에 interface를 사용하는 법에 대해 간단히 살펴보겠습니다. 별도로 타입을 구조화시켜 이름을 붙여 사용할 수 있습니다. 그리고 이 Interface를 module화 시켜 필요한 곳에 재사용 할 수 있습니다.

interface Address {
  houseNumber: number;
  streetName?: string;
}

const ee: Address = { houseNumber: 33 };

Intersection & Union Types

Intersection Types

Intersection Type을 사용하면, 필요시 여러 타입을 하나의 타입으로 합쳐 사용할 수 있습니다. HasPhoneNumber와 HasEmail Interface 가 가지고 있는 모든 속성을 포함하는 Interface 를 만들수 있습니다.

let contactInfo: HasEmail & HasPhoneNumber = {
  name: 'eric',
  email: 'eric@watcha.com',
  phone: 1234567
};

contactInfo.name;
contactInfo.email;
contactInfo.phone;

Union Type

Union Type은 여러 타입 중 하나의 타입이 할당 가능한 경우에 사용할 수 있으며, vertical bar( | )로 타입을 구분합니다.

type AB = 'A' | 'B';

Union Type의 값을 가지고 있다면, 모든 타입에 공통으로 존재하는 속성만 접근이 가능합니다.

아래에 Car와 Plane 두 개의 Interface가 있습니다. 두 Interface 모두 공통으로 stop property를 가지고 있습니다. 그리고 transportation라는 변수의 Type을 Car 또는 Plane으로 지정합니다.

그러고 나면 우리는 transportation 객체의 stop property에는 접근이 가능하지만, move나 fly 메소드에 접근하면 Typescript가 에러 메시지를 보여줍니다.

interface Car {
  move(): void;
  stop(): void;
}

interface Plane {
  fly(): void;
  stop(): void;
}

const transportation: Car | Plane =
  Math.random() > 0.5
    ? {
        move: () => {
          console.log('move!');
        },
        stop: () => {
          console.log('stop!');
        }
      }
    : {
        fly: () => {
          console.log('fly!');
        },
        stop: () => {
          console.log('stop!');
        }
      };

transportation.stop();
transportation.fly();
// Property 'fly' does not exist on type 'Car | Plane'.
// Property 'fly' does not exist on type 'Car'.

Type Guard

Union Type을 사용하고, Type Guard를 이용해 타입을 좁혀 사용할 수 있습니다. Type Guard란 런타임에서 타입이 일정 범위 안에 있는 것을 보장하는 것입니다.

if (transportation.stop) {
  transportation.stop();
}

if (transportation.fly) {
  transportation.fly();
}

Type Guard를 정의하기 위해, 타입을 예상하기 위한 helper 함수를 아래와 같이 만들 수 있습니다. 타입스크립트는 if 문에서 타입이 Plane 인것을 인지할 수 있을 뿐만 아니라, else 문에서 Plane이 아닌것을 확인할 수도 있습니다.

function isPlane(transportation: Car | Plane): transportation is Plane {
  return (transportation as Plane).fly !== undefined;
}

if (isPlane(transportation)) {
  transportation.fly();
} else {
  transportation.move();
}

Type Systems (Norminal Type System & Structural Type System)

Typesciprt의 Type System과 Java와 같은 프로그래밍 언어에서의 타입과의 차이점에 대해 간단히 살펴볼게요.

function validateInputField(input: HTMLInputElement) {
  // ... 생략 ... //
}

validateInputField(x);

위 예에서 타입을 체크할 때 대부분의 언어에서 함수의 argument로 전달 받은 데이터가 지정된 타입의 instance 인지 확인을 통해 타입을 체크합니다. 하지만, 자바스크립트는 클래스 기반의 언어가 아니기 때문에 이러한 방식으로 타입을 체크하기 위해서 많은 공수가 필요할 것입니다.

Typescript는 Structural Type System 기반으로 작동합니다. 객체의 구조, property와 그에 상응하는 값의 타입이 옳은지만을 확인합니다. 함수에서도 같은 방식으로 동작합니다. Typescript는 argument의 타입과 함수가 반환하는 타입이 같다면 동등하게 간주합니다. 만약 다른 함수가 Typescript가 체크하는 위의 기준에 부합한다면, Typescript는 에러로 간주하지 않습니다.

wider / narrower

Typescript에서는 wider, narrower와 같은 개념이 있는데, 타입을 얼마나 구체적으로 정의하는지에 대한 기준으로 사용하는 용어입니다. wider 한 type은 타입 규정에 대한 기준이 더 포괄적이기 때문에, narrower type은 상위의 wider 타입에 항상 사용할 수 있습니다.

Function Signature Overloading

아래의 contactPeople 함수는 첫번째 인자로 string 타입인 "email" 또는 "phone"을 argument로 받습니다. 그리고 rest parameter인 people의 타입은 배열이며 위쪽에서 정의한 HasEmail 또는 HasPhoneNumber 인터페이스에 만족하는 요소를 가집니다.

function contactPeople(method: 'email' | 'phone', ...people: (HasEmail | HasPhoneNumber)[]): void {
  if (method === 'email') {
    (people as HasEmail[]).forEach(sendEmail);
  } else {
    (people as HasPhoneNumber[]).forEach(sendTextMessage);
  }
}

contactPeople('email', { name: 'foo', email: '' });
contactPeople('phone', { name: 'foo', email: 12345678 });
contactPeople('email', { name: 'foo', phone: 12345678 });

contactPeople 함수를 세 번 호출했는데, 직관적으로 코드를 봤을 때 위 코드를 작성한 개발자의 의도는 아마도 세 번째 함수에서는 오류가 나기를 고려했을 수 있습니다.

하지만, Typescript의 관점에서 위 코드는 문제가 없습니다. 첫 번째 인자로 "email" 또는 "phone"을 받으면 되고, 두 번째 인자로 HasEmail 또는 HasPhoneNumber 인터페이스만 충족하면 되기 때문입니다.

위의 경우 별도의 function signature를 추가해, 본래 의도한 대로 프로그래밍을 할 수 있습니다.

function contactPeople(method: 'email', ...people: HasEmail[]): void;
function contactPeople(method: 'phone', ...people: HasPhoneNumber[]): void;

아래의 코드를 추가하고, 함수를 호출하면 두 가지 함수를 호출할 방법 중 한 가지를 선택할 수 있습니다. function signature를 작성하고 함수 선언식을 작성할 때, 작성하는 함수 선언식은 모든 function signature를 포함할 수 있도록 작성해야 합니다. 만약 function declaration이 function signature를 모두 포괄하지 못하면 Typescript는 에러를 표시할 것입니다.

Interface & Type Aliases

Interface와 Type Aliases는 모두 변수에 타입에 대한 구조를 담을 수 있도록 해 줍니다. 이 기능을 사용하면 한 곳에서 타입에 대한 정의를 하고 모듈화하여 사용할 수 있는 장점이 있습니다.

Interface

Interface를 이용해 객체의 속성 이름과 그 값에 대한 타입을 정의함으로써, 애플리케이션에 하나의 커스텀 타입을 생성하는 것과 같은 효과를 줄 수 있습니다. 아래는 단순히 type annotation을 사용해 악기를 로그 해 보는 코드입니다.

const myGuitar = {
  name: 'Furch-G24 SR Cut',
  manufacturer: 'Furch',
  year: 2013
};

const printGuitar = (guitar: { name: string; manufacturer: string; year: number }): void => {
  console.log(`name: ${guitar.name}`);
  console.log(`manufacturer: ${guitar.manufacturer}`);
  console.log(`year: ${guitar.name}`);
};

물론 위와 같이 작성해도 문제는 없지만, 객체에 속성이나 메소드가 추가될수록 type annotation이 길어지기 때문에 읽기가 쉽지 않을 것입니다. 또한 속성에 대한 표기를 annotation에서 중복해서 작성하는 문제도 있습니다. Interface를 사용하면 이러한 문제를 해결할 수 있습니다.

interface Reportable {
  summary(): string;
}

const myGuitar = {
  name: 'Furch-G24 SR Cut',
  manufacturer: 'Furch',
  year: new Date(),
  summary(): string {
    return `name: ${guitar.name} manufacturer: ${guitar.manufacturer} year: ${guitar.name}`;
  }
};

const printSummary = (item: Reportable): void => {
  console.log(item.summary());
};

위와 같이 Typescript에서 코드 재사용을 위해 일반적으로 함수가 annotation 대신 interface를 취하도록 설계합니다. 어떠한 함수가 있으면 문지기 역할을 하는 interface를 생성하고, 이를 통해 함수를 호출하도록 기획할 수 있습니다.

Type Aliases의 개념은 단순히 Typescript의 타입에 아래와 같이 이름을 붙여주는 것입니다. 아래 변수 x의 type annotation 영역에 어떠한 내용이 오던 Type Aliases는 이를 대체할 수 있습니다. 이 점이 Type Interface와는 다른점이며, 이 때문에 Type Aliases가 Interface보다는 좀 더 유연합니다.

type StringOrNumber = string | number;
const x: string | number;

Interface는 Javascript의 클래와 같은 방식으로 다른 Interface를 extend 할 수 있습니다.

export interface HasInternationalPhoneNumber extends HasPhoneNumber {
  countryCode: string;
}

객체와 함수를 Typescript의 Interface로 지정할 수 있지만, primitive value를 Interface로 지정할 수는 없습니다. 함수를 Interface로 지정할 때는 아래와 같은 방식으로 지정할 수 있습니다.

interface ContactMessenger1 {
  (contact: HasEmail | HasPhoneNumber, message: string): void;
}

참고로 type aliases로 함수의 타입 지정은 아래와 같이 할 수 있으며, Interface와는 다르게 화살표로 함수의 리턴값을 표기합니다.

type ContactMessenger2 = (contact: HasEmail | HasPhoneNumber, message: string) => void;

Objects and Index Signatures

자바스크립트에서 객체에 속한 속성 값으로 해당하는 값에 접근할 수 있습니다. 아래의 phoneNumberDict 의 값에 속성으로 접근을 하면, 값이 있는 경우 그 값을(이 경우 객체) 반환하고, 없는 경우 undefined를 반환합니다.

interface PhoneNumberDict {
  [numberName: string]:
    | undefined
    | {
        areaCode: number;
        num: number;
      };
}

객체의 Interface를 구성할 때 위와 같이 falsy 값이 있는 경우를 고려하는 것이 좋습니다. PhoneNumberDict interface에 undefined 값을 고려하지 않으면 아래와 같은 interface가 됩니다.

만약 위와 같이 undefined의 경우를 고려하지 못하고 아래와 같이 interface를 구성하면 contactInfo에 어떠한 값으로 접근을 하던 Typescript는 truthy한 값이 담겨 있는 것을 기대합니다.

이 경우 에러가 발생하기 전까지 에러를 인지하지 못 할 수 있고 디버깅 하는 것이 쉽지 않습니다. 따라서, contactInfo의 값이 undefined일 경우 발생할 수 있는 에러를 피하고자, 위와 같은 코드는 피하는 것이 좋습니다.

Classes

일반적인 자바스크립트 Class의 사용은 아마도 익숙할 것입니다. 자바스크립트에서 Class는 특정 fields, methods를 포함한 객체의 뼈대를 만들고 싶을 때 사용할 수 있습니다. 또는 추상화한 객체를 만들고 싶을 때도 사용할 수 있습니다. Typescript의 Class는 일반 Javascript의 Class에 몇몇 새로운 개념을 도입했습니다.

Typescript의 클래스에는 Access modifier keywords라는 개념이 있습니다. 이 키워드를 사용함으로써 클래스의 인스턴스에 속한 field 또는 method의 접근 범위를 조정할 수 있습니다.

자바스크립트에서 다른 클래스의 속성, 메소드를 확장한 객체를 사용할 때 extends를 사용해 확장할 수 있습니다. Typescript에서는 이와 유사한 기능을 하는 implements를 사용할 수 있습니다. implements는 클래스가 어떠한 interface의 기준을 준수함을 나타낼 때 사용합니다.

단순히 implements를 사용해 Type에 제한을 둔 클래스는 아래와 같이 작성할 수 있습니다.

export class Contact implements HasEmail {
  email: string,
  name: string,

  constructor(name: string, email; string) {
    this.email = email;
    this.name = name;
  }
}

하지만 위 코드는 조금 장황합니다. email과 name을 class field와 constructor의 argument, constructor 내부 세 곳에 명시했습니다. 이 부분을 개선하려는 방법으로 Typescript에 PARAMETER PROPERTIES라는 것이 존재합니다.

Access modifier keywords

public -> everyone
protected -> me and subclasses
private - only me

public의 일반 자바스크립트의 그것과 같습니다. protected를 사용하면 클래스의 인스턴스와 서브클래스 인스턴스만이 해당 속성에 접근할 수 있습니다. private의 경우는 해당 클래스만이 접근을 할 수 있습니다.

class ParamPropContact implements HasEmail {
  constructor(public name: string, public email: string = 'no email') {
    // nothing needed
  }
}

앞서 작성한 Contact 클래스를 public 키워드와 함께 사용하면 아래와 같이 작성할 수 있습니다. constructor의 parameter에 public 키워드를 추가해, 같은 이름의 field가 인스턴스에 존재하도록 합니다.

React나 Vue, Angular 등의 라이브러리 또는 프레임워크를 사용하면 컴포넌트를 인스턴스화 하는 작업은 일반적으로 라이브러리나 프레임워크가 대신해 줍니다. App 작업을 하다 보면, Component의 field 중 초기화 작업에서는 값이 없지만, life cycle을 타며 값이 할당되는 경우도 있습니다.

이처럼 lazy 하게 instance field에 값을 할당해야 하는 경우 아래와 같은 방식으로 진행할 수 있습니다.

class OtherContact implements HasEmail, HasPhoneNumber {
  protected age = 0;
  private passwordValue: string | undefined;

  constructor(public name: string, public email: string, public phone: number) {
    this.age = 20;
  }

  get password() {
    if (!this.passwordValue) {
      this.passwordValue = Math.round(Math.random() * 1e14).toString(32);
    }
    return this.passwordValue;
  }
}

Converting to Typescript

타입스트립트의 장점 중 하나는 자바스크립트와 호환이 매우 좋다는 것입니다. 기존 자바스크립트를 타입스크립트로 변환하는 방법에 대해 알아보기 전에, 타입스크립트를 변환할 때 피해야 할 사항에 대해 먼저 확인해 보도록 하겠습니다.

JS -> TS 변환 시 피해야 할 사항

  • 기능적인 변화

    특정 값이 undefined인지 타입을 체크하는 것과 특정 값이 truthy인지 또는 falsy인지 확인하는 것은 엄연한 차이가 존재합니다. 이에 따라 null, 0, 빈 문자열이 다르게 다루어질 것입니다. 자바스크립트에서 타입스크립트로 변환할 때 이러한 작은 변화도 피하는 것이 좋습니다.

  • Test Coverage가 낮은 상태에서 변환 시도하기
  • 처음부터 너무 엄격한 타입 제한하기

    타입 제한을 좀 더 엄격하게 하면 그에 따른 이점이 있지만, 자바스크립트를 타입스크립트로 변환하는 이른 시기에 너무 많은 제한을 하지 않는 것이 좋습니다. 우선 자바스크립트를 타입스크립트로 변환 후 점진적으로 타입에 대한 제한을 높이는 것이 좋습니다.

    한 번에 여러 파일의 타입 제한을 strong 하게 가져간다면 이후에 이 타입스크립트 코드와 상호 작용하는 자바스크립트 코드를 변환할 때 타입에 대한 제한을 낮은 기준으로 시작할 수 없게 됩니다. 이미 상호 작용하는 변환된 타입스크립트 코드에 대한 제한이 strong 하게 적용돼 있기 때문입니다.

  • 타입에 대해 테스트를 하지 않는 것
  • 이른 시기에 Consumer use로 사용하는 것 타입스크립트로 변환 후 타입에 대한 제약이 아직 약한 상태에서 코드를 공개해 버리면, 약한 타입에 대한 기준이 허용 가능한 것으로 인식되고 계속 그러한 방식으로 사용될 수 있습니다. 따라서, 충분히 변환된 코드가 단단해 졌을 때 consumer use로 전환하는 것이 좋습니다.

1단계. Compiling in "loose mode"

기존의 자바스크립트 파일을 타입스크립트 파일로 변환하기 위해, 첫 번째로 "loose mode"로 컴파일을 합니다.

  • 첫 번쨰로 변환된 타입스크립트 파일이 테스트를 패스하는지 확인합니다.
  • .ts 파일로 이름을 변경하고 any 타입을 허용합니다. 타입스크립트 inference가 기존 파일에서 구체적인 타입을 찾을 수 없는 경우 any 타입으로 지정될 수 있도록 합니다. 예로, 함수의 argument의 경우 변환 시 타입스크립트가 타입을 알아채지 못 하므로 any로 지정이 될 수 있습니다.
  • 타입 체크가 되지 않은 것과 컴파일 에러가 발생하는 부분을 수정합니다.
  • 기존의 코드 동작이 변경되지 않도록 주의합니다.
  • 타입스크립트로 변환 후 수정한 파일이 테스트를 통과하는지 다시 확인합니다.

Generics

변수에 대한 제약을 두고자 할 때 Generic을 사용할 수 있습니다. C#이나 Java와 같은 언어에서 재사용 가능한 컴포넌트를 생성하는 주요 툴 중 하나가 Generics이며, 이는 컴포넌트를 하나의 타입에 국한하지 않고 다양한 타입에 사용할 수 있도록 작성하는 데 사용됩니다.

Generic을 사용하면 미래에 사용할 property, argument, return 값의 타입을 지정할 수 있습니다. Generic은 함수가 매개변수를 다루는 방식과 같이 타입을 다룹니다.

function reverse<T>(items: T[]): T[] {
  const toreturn = [];
  for (let i = items.length - 1; i >= 0; i--) {
    toreturn.push(items[i]);
  }
  return toreturn;
}

const sample = [1, 2, 3];
const reversed = reverse(sample);
console.log(reversed); // 3,2,1

// Safety!
reversed[0] = '1'; // Error!
reversed = ['1', '2']; // Error!

reversed[0] = 1; // Okay
reversed = [1, 2]; // Okay

Generic의 Parameter에 임의의 이름을 지어줄 수 있지만, 일반적으로 C++에서 'template'에서 유래해서 convention으로 사용하는 T를 이름으로 사용합니다. Type parameter도 함수와 같이 default type을 가질 수 있습니다.

interface FilterFunction<T = any> {
  (val: T): boolean;
}

const stringFilter: FilterFunction<string> = val => typeof val === 'string';
stringFilter(0); // 🚨 ERROR
stringFilter('abc'); // ✅ OKs

Type parameter 역시 특정 제약을 가질 수 있습니다. 아래의 예에서, Type parameter T에 대한 정의가 없을 때 id라는 속성에 접근하면 Typescript는 에러를 발생시킵니다. 이는 T에 대한 최소한의 요구 사항이 없기 때문입니다. id에 접근을 위해 T는 최소한 객체이며 id라는 속성을 가지고 있음을 명시해야 합니다.

generic

Type parameter는 자바스크립트 함수에서와같이 scope와 연관되어 작동합니다. finishTuple 함수 밖에서 argument b에 접근할 수 없듯이 type parameter U에도 접근할 수 없습니다.

function startTurple<T>(a: T) {
  // U에 접근할 수 없음
  return function finishTuple<U>(b: U) {
    return [a, b] as [T, U];
  };
}
const myTuple = startTuple(['first'])(42);

When to use generics

generics은 매우 유용하지만 한편으론 지나치게 사용할 경우 이로 인해서 타입이 매우 복잡해지는 경우를 초래할 수 있습니다. 아래 코드에서 shapes 배열을 돌며 draw 메소드를 호출하고 있습니다.

interface Shape {
  draw();
}

function drawShapes1<S extends Shape>(shapes: S[]) {
  shapes.forEach(s => s.draw());
}

위 코드를 아래와 같이 기본 interface를 사용해 간단하게 작성할 수 있습니다.

function drawShapes2(shapes: Shape[]) {
  shapes.forEach(s => s.draw());
}

generics은 어떠한 두 개를 연관 지을 때 사용하면 편리합니다. 예로, type parameter T가 요소인 배열을 받아 S를 값으로 가지는 객체를 반환할 때 사용할 수 있을 것입니다. Type parameter를 한 번만 사용하는 경우는 굳이 generics을 이용할 필요가 없을 것입니다.

그러면 dictionary를 인자로 전달받아 각 속성에 대한 값을 매핑하는 함수를 만들며 generic을 사용하는 방식을 살펴보겠습니다.

type Dict<T> = {
  [k: string]: T | undefined;
};

function mapDict<T, S>(dict: Dict<T>, fn: (arg: T, index: number) => S): Dict<S> {
  const out: Dict<S> = {};

  Object.keys(dict).forEach((dKey, idx) => {
    const dicItem = dict[dKey];
    if (typeof dicItem !== 'undefined') {
      out[dKey] = fn(dicItem, idx);
    }
  });

  return out;
}

유사하게 dictionary에 reduce 함수를 아래와 같이 generic과 함께 작성할 수 있습니다.

export function reduceDict<T, S>(dict: Dict<T>, reducer: (val: S, item: T, idx: number) => S, initialVal: S) {
  let val: S = initialVal;
  Object.keys(dict).forEach((dKey, idx) => {
    const thisItem = dict[dKey];
    if (typeof thisItem !== 'undefined') {
      val = reducer(val, thisItem, idx);
    }
  });
  return val;
}

Top & Bottom Types

Private value를 typed code 로 어떻게 변환하는지와 Type guard에 대해 알아보겠습니다.

Typescript에서 Top type은 어떠한 타입의 값도 수용하는 것을 의미합니다.

let myAny: any = 22;
let myUnknown: unknown = 'Hello, unknown';

위 두 가지의 차이점은 any는 어떠한 값도 담을 수 있기 때문에 아래와 같이 네스팅 객체로 간주하여 접근 할 수 있습니다. unknown 의 경우도 어떠한 값을 담을 수 있지만 값을 바로 사용할 수 없습니다. unknown에 담긴 값을 사용하기 위해서는 Type을 narrow하게 만들어야 합니다. 이에 대한 한 예롤 API 응답을 들 수 있습니다. 뒤 쪽에서 Type Guard를 이용해 Type을 좁히는 방법에 대해 살펴보겠습니다.

myAny.foo.bar.baz;
muUnknow.foo; // type error가 발생함

Patterns

Structural Typing

타입스크립트의 타입 체킹에 대한 이해가 우리가 코드를 작성하며 의도한 것보다 더 넓을 수 있습니다. 따라서, 이에 대한 이해를 하는 것이 타입스크립트 코드를 작성하거나, 에러를 파악하는데 도움이 될 것입니다.

다음과 같이 2D vector와 legnth를 구하는 함수가 있습니다. Vector2D와 NamedVector 와 연관된 코드가 없고 NamesVector는 Vector2D에 추가된 속성이 있지만, 타입스크립트는 에러를 발생시키지 않습니다. 이 경우 에러를 원한다면 별도의 옵션을 설정할 수는 있습니다.

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

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

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

const v: NamedVector2D = { x: 3, y: 4, name: 'Zee' };

calculateLength(v); // Result is 5.

다음의 예에서 주목할 점은 타입 체킹이 에러를 발생시키는 것입니다. v[axis]로 접근한 값의 타입이 any로 할당이 됩니다. 함수 calculateLengthL1의 argument 타입이 Vector3D 이지만, 타입이 number가 아닌 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 ...
    length += Math.abs(coord);
  }

  return length;
}

const vec3D = { x: 3, y: 4, z: 1 };

이는 코드를 작성하는 개발자 입장에서 Vector3D가 sealed이고 다른 속성을 포함하지 않는다는 전제하에 작성 했을 수 있습니다. 하지만, 아래와 같이 다른 속성을 가질 수도 있습니다.

const vec3D = { x: 3, y: 4, z: 1, address: 'some address' };
calculateLengthL1(vec3D); // returns NaN

함수의 인자인 객체가 다른 속성을 가질 수 있으므로, v[axis]에 대한 값을 타입스크트가 확신할 수 없어 any가 됩니다. (객체에 대한 loop은 뒤쪽에서 좀 더 살펴보도록 하겠습니다.) 위 NaN 을 반환하는 버그에 대한 해결책은 아래와 같이 명시적으로 속성을 적어 값을 계산하는 방법으로 해결할 수 있습니다.

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

Think of Types as Sets of Values

런타임에서 자바스크립트 변수는 하나의 값을 가지고 있지만, 타입스크립트가 타입 체킹을 할 때에는 type만 가지고 있습니다. type은 할당 가능한 값의 집합체 또는 할당 가능한 값의 범위로(domain) 생각하면 좋습니다. 가장 작은 집합은 값이 없는 never 이며, 다음으로 작은 set은 아래와 같은 unit type으로 하나의 값을 가지고 있습니다.

const x: never = 12;
// Initializer type number is not assignable to variable type never

// unit type
type A = 'A';

복수의 값을 담으려면 union unit type을 사용할 수 있습니다.

// union unit type
type AB = 'A' | 'B';

const c: AB = 'C';
// type "C" is not assignable to variable type AB

타입스크립트를 사용하면 위와 같이 assignable 할 수 없다는 에러 메세지를 자주 보게 됩니다. "assignable "의 의미는 해당 값이 해당 타입에 속하는지, 또는 해당 타입의 subset이 될 수 있는지를 의미합니다. 결론적으로, 타입 체킹이란 하나의 타입이 다른 타입의 subset 이 될 수 있는지 없는지를 확인하는 작업입니다.

type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;

const ab: AB = Math.random() < 0.5 ? 'A' : 'B';
// OK, {"A", "B"} is a subset of {"A", "B"}:

const ab12: AB12 = ab;
// OK, {"A", "B"} is a subset of {"A", "B", 12}

declare let twelve: AB12;
const back: AB = twelve;
// Type 'AB12' is not assignable to type 'AB'
// 타입 "AB12"는 타입 "AB"의 subset이 될 수 없음.

// Type '12' is not assignable to type 'AB'
// 타입 "12"는 타입 "AB"에 해당하는 값이 아님

포스팅 초반에 & Operator를 이용해 intersection을 만드는 과정을 간략히 살펴보았습니다. 두 인터페이스의 Intersection 타입은 두 인터페이스의 모든 속성을 포함합니다. 이것은 타입 시스템 타입 정의가 값의 속성을 보는 것이 아니라 값의 범위(or 집합)을 기준으로 하기 때문입니다. 따라서, Person과 LifeSpan과 이들에 대한 속성은 Intersection Type에 속합니다.

interface Person {
  name: string;
}

interface Lifespan {
  birth: Date;
  death?: Date;
}

type PersonSpan = Person & Lifespan;

const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07')
}; // OK

Intersection에 대한 것은 어느정도 직관적이라 할 수 있지만, 인터페이스로 구성된 유니온 타입은 그렇지 않을 수 있습니다.

type K = keyof (Person | LifeSpan); // Type is never

타입스크립트 입장에서 위의 코드는 어떠한 key가 Union 타입에 속할지 보장할 수 없기 때문에 타입 K는 never가 됩니다. 이것을 정리해 보면 다음과 같이 나타낼 수 있습니다.

keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B)

위 타입에 대한 개념이 처음에는 이해가 잘 안됐는데 여러번 보니 조금 감이 오는것 같습니다. 타입을 범위 또는 집합의 개념으로 접근하면 좀 더 이해가 수월했던 것 같습니다.

타입스크립트를 배우며 "subtype" 이라는 용어를 접해 봤을 것입니다. 의미하는 바는 한 타입의 범위가 다른 타입에 속하는, 부분 집합이 되는 것을 말합니다. 간단히 예로 들면 extends를 사용하는 interface를 예로 들 수 있습니다.

interface Vector1D {
  x: number;
}
interface Vector2D extends Vector1D {
  y: number;
}
interface Vector3D extends Vector2D {
  x: number;
}

위 인터페이스 간 관계를 계층 구조로도 나타낼 수 있는데, 타입에 대한 개념을 이해하기 위해 값의 집합으로 표현한 벤다이어그램으로 생각하면 이해하기 좀 더 쉬울 수 있습니다.

벤다이어그램으로 봤을 때 subset /subtype / assignability에 대한 관계를 더 이해하기가 쉬운 것 같습니다. 위 interface를 아래와 같이 작성해도 맥락은 같습니다.

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

특히 리터럴 타입과 유니온 타입을 다루며, 이렇게 집합 개념으로 타입에 대한 관계에 접근할 때 좀 더 직관적입니다.

function getKey<K extends string>(val: any, key: K) {
  // ...
}

extends string이 의미하는 바가 무엇일까요? 만약 객체 상속과 같이 생각한다면 이해하기가 쉽지 않을 수 있습니다. 반면, 집합에 대한 개념으로 접근하면 이해하기가 수월합니다. 이것은 string literal 타입이 될수도 있고, union 타입이나 그냥 string이 될 수도 있습니다.

getKey({}, 'x'); // Ok, "x" extends string
getKey({}, Math.random() < 0.5 ? 'a' : 'b'); // OK, "a" | "b" extends string
getKey({}, document.title); // Ok, string extends string
getKey({}, 12); // Type 12 is not assignable to parameter of type "string"

계층적인 관계가 아닌 타입을 다룰 떄도 이러한 개념은 도움이 됩니다. string | number와 string | Date 타입 간 관계는 어떻게 될까요? 계층적인 구조로 이해하려 하면 잘 되지 않지만, 다르게 접근하면 이해가 수월합니다.

배열과 Turple 간의 관계를 이해하기도 좋습니다.

const list = [1, 2]; // Type is number[]
const tuple: [number, number] = list;
// ~~~~~ Type 'number[]' is missing the following
// properties from type '[number, number]': 0, 1

list의 타입은 number[]이고 tuple은 [number, number]이기에 list는 tuple의 subset이 될 수 없습니다.

정리해보면, 타입스크립트의 타입을 집합의 개념으로 접근하면 좋습니다. boolean이나 literal type과 같이 제한적일 수도 있고, nubmer나 string과 같이 제한이 없을 수도 있습니다. "extends", "assignable to", "subtype of "는 "subset of"와 같은 의미로 이해할 수 있습니다.

Limit Use Of the any Type

any 타입은 타입스크립트의 type checker의 효용을 없애고, 에디터 상 language service도 사용할 수 없게 합니다. 무엇보다 개발자 경험을 안좋게 만들며 타입시스템에 대한 자신감을 떨어뜨립니다. 그래서 가급적 any를 사용하지 않도록 해야합니다.

function calculateAge(birthDate: Date): number {
  return birthDate.getDate();
}

let birthDate: any = '123456';
calculateAge(birthDate);

any를 사용한 경우의 단편적인 예이지만, 위의 코드는 타입시스템의 제약을 해합니다. 쉽고 깔끔한 코드 작성을 위해 좋은 타입 디자인은 필수이지만, any 타입을 사용하면 타입 디자인이 명시적이지 않습니다. 또, 좋은 디자인인지 아닌지 알기 어렵습니다. 물론 동료가 코드를 이해하기도 더 어려울 것입니다. 좀 더 편한 개발을 위해 도입한 거지만 any를 사용할 경우 개발자가 여전히 머릿속에서 타입을 인지해야 하는 비용이 발생합니다.

Excess Property Checking의 한계 이해하기

선언이 된 타입에 Object literal로 객체를 할당하면, 타입스크립트는 해당 타입에 명시된 속성 외에 다른 속성이 추가되지 않는지 확인합니다.

interface Room {
  numDoors: number;
  ceilingHeight: number;
}

const room406: Room = {
  numDoors: 1,
  ceilingHeight: 500,
  bed: "present",
};
//  Object literal may only specify known properties, and 'bed' does not exist in type 'Room'.

하지만, 위 에러는 앞서 정리한 Structural Typing 관점에서 보면 이해가 가지 않습니다. 같은 객체를 변수에 담아 할당하면 에러가 나지 않는 것을 확인할 수 있습니다.

const obj = {
  numDoors: 1,
  ceilingHeight: 500,
  bed: "present",
}

const room200: Room = obj; // OK

객체 obj의 타입은 { numDoors: number; ceilingHeightFt: number; bed: string } 로 inference 됩니다. 이 타입은 Interface Room 타입의 값을 subset으로 가질 수 있기 때문에, 타입 체킹 시 에러가 나지 않는 것입니다.

위 두 예의 차이점은 무엇일까요? 첫 번째 예는 excess property checking을 하기 때문에 structural type system 으로는 놓칠 수 있는 에러를 찾을 수 있습니다. 하지만, 타입스크립트를 사용하면 일반적으로 타입 할당 시 이 개념을 함께 생각하면, structural typing에 대한 개념을 잡기가 쉽지 않을 수 있습니다. 따라서, excess property checking 에 대한 개념과 과정에 대해 명확히 이해하는 것이 타입스크립트의 타입 시스템을 이해하는데 꼭 필요합니다. 첫 예시는 object literal로 값이 할당이 돼서 excess property checking이 작동 되었고, 후자는 그렇지 않습니다.

Type assertion을 사용하면 에러를 피해갈 수 있지만, 가능하면 assertion을 사용하지 말아야 하는 또다른 이유가 될 수 있겠네요.

const root406 = {
  numDoors: 1,
  ceilingHeight: 500,
  bed: "present",
} as Room; // No Error

만약 Excess Property Check을 원하지 않는다면, index signature를 이용해 타입스크립트에게 추가 속성이 있을 수 있다고 말할 수 있어요. 이러한 방법이 언제 적절하고 그렇지 않은지 뒤에서 다시 살펴볼게요.

interface Room {
  numDoors: number;
  ceilingHeight: number;
  [others: string]: unknown;
}

정리해보면, excess property checking은 타이포나 속성 이름에 대한 개발자의 실수를 찾아내기에 좋습니다. 하지만, object literal에서만 동작하기 때문에 excess property checking과 일반 타입 체킹을 구분할 줄 알아야 타입스크립트의 타입 시스템에 대한 이해를 높일 수 있습니다.

Type Operation and Generics to Avoid Repeating Yourself

개발자들은 중복을 하지 않으려 합니다. 하지만, 타입에 관해서는 코드를 작성할 때보다 조금 느슨하게 생각하는 개발자 분들도 있을 것입니다.

타입에 대한 중복도

Null 거르기

어떤 값이 특정 값을 가지면서 null일 수도 있는 경우보다, 온전히 값만 가지고 있고 null이 될 수 없는 경우가 코드를 읽기도, 개발자가 코드를 다루기도 훨씬 수훨합니다. 아래 함수는 숫자 배열을 인자로 받아 최소, 최대값을 반환합니다.

function extent(numbs: number[]) {
  let min, max;

  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }

  return [min, max];
}

위 함수는 버그와 디자인 적인 문제가 있습니다. 인자로 [0, 1, 2]를 받으면 [1,2]를 반환하는 버그가 있습니다. 그리고, 빈 배열을 받으면 반복문이 돌지 않기 때문에 [undefined, undefined]를 반환합니다. 이러한 undefined 는 클라이언트에서 다루기 어렵고, 개발자가 코드를 읽으면 알 수 있겠지만, type system에 표현이 되지 않습니다.

더 좋은 방법으로는 min, max 값을 한 객체에 넣고 이 객체를 온전히 null이거나 온전히 null이 아니도록 만드는 것입니다.

function extent(nums: number[]) {
  let result: [number, number] | null = null;

  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }

  return result;
}

위와 같이 수정하면 함수의 반환값 [number, number] | null인 경우로 좁힐 수 있습니다. 반환값에서 null인 경우를 제하고 싶다면 non-null assertion을 추가하거나, if 문 조건을 하나 추가해 거를수 있습니다.

const [min, max] = extent([0, 1, 2])!;

// or

const range = extent([0, 1, 2]);

if (range) {
  // do something here.
}

클래스를 다룰 때에도 null이 섞여 있 문제가 되는 경우가 있습니다.어 포럼에서 유저와 유저의 글을 다루는 클래스가 있다고 가정해 보아요.

class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;

  constructor() {
    this.user = null;
    this.posts = null;
  }

  async init(userId: string) {
    return Promise.all([
      async () => (this.user = await fetchUser(userId)),
      async () => (this.posts = await fetchPostsForUser(userId))
    ]);
  }

  getUserName() {
    // ...?
  }
}

두 요청이 나가면, user와 posts는 null이 할당될 것입니다. 둘 다 null인 경우, 둘 다 null이 아닌 경우, 둘 중 하나만 null 인 경우가 발생할 수 있습니다. 네 가지의 경우가 생기는 것입니다. 이 복잡성은 여러 메소드를 구현할 때마다 확장될 수 있으면 null을 체크해야 하는 상황도 많아질 것입니다. class의 static 메소드를 이용해 fully non-null class 로 만들 수 있습니다.

class UserPosts {
  user: UserInfo;
  posts: POst[];

  constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }

  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([fetchUser(userId), fetchPostsForUser(userId)]);

    return new UserPosts(user, posts);
  }

  getUserName() {
    return this.user.name;
  }
}

위와 같이 작성해 user와 post가 null인 경우를 제하고 메소드를 작성할 수 있습니다.

Unions of Interfaces vs Interfaces of Unions

Union Type을 속성으로 가진 Interface를 생성했다면, 좀 더 정교한 Interface로 이루어진 Union Type으로 대체할 수 있지 않은지 생각해 보면 좋습니다. vector drawing을 그리는 프로그램을 위해 Interface 를 생성한다고 가정해 봅시다.

interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaing;
}

그림의 모양을 나타내는 layout 필드와 스타일을 정하는 paint 필드가 있습니다. Layout 필드와 Paint 필드는 서로 짝이 지어져 있고 연관이 없는 짝과 이어진다면 에러가 발생할 수 있습니다. 각 Layer 별로 Interface 를 구분하는 것이 더 좋은 방법이 될 수 있습니다.

interface FillLayer {
  layout: FillLayout;
  paint: FillPaint;
}

interface LineLayer {
  layout: LineLayout;
  paint: LinePaint;
}

interface PointLayer {
  layout: PointLayout;
  paint: PointPaint;
}

type Layer = FillLayer | LineLayer | PointLayer;

위 구조로 코드를 작성하면, layout과 paint 속성이 섞이는 문제를 해결할 수 있습니다. 이것은 앞에서도 언급했었던, 가능하면 타입이 유효한 상태를 나타내도록 설계하는 것이 좋다는 방법론과도 일치합니다.

위 방식의 가장 일반적인 패턴은 "tagged union"("discriminated union")입니다.

interface FillLayer {
  type: 'fill';
  layout: FillLayout;
  paint: FillPaint;
}

interface LineLayer {
  type: 'line';
  layout: LineLayout;
  paint: LinePaint;
}

interface PointLayer {
  type: 'point';
  layout: PointLayout;
  paint: PointPaint;
}

type Layer = FillLayer | LineLayer | PointLayer;

Interface에 type 속성이란 tag를 추가했고, 이를 이용해 런타임에서 어떠한 타입의 Layer를 다루는지 결정할 수 있습니다. 타입스크립트 /도한 이 태그를 기준으로 타입을 좁힐 수 있습니다.

function drawLayer(layer: Layer) {
  if (layer.type === 'fill') {
    const { paint } = layer; // Type is FillPaint
    const { layout } = layer; // Type is FillLayout
  } else if (layer.type === 'line') {
    const { paint } = layer; // Type is LinePaint
    const { layout } = layer; // Type is LineLayout
  } else {
    const { paint } = layer; // Type is PointPaint
    const { layout } = layer; // Type is PointPaint
  }
}

Typescript의 타입 체커와 호환이 매우 좋기 때문에, 이 패턴은 많이 사용되고 있습니다. Optional 필드를 undefined와 해당 타입의 Union Type으로 생각할 수 있다면 이 패턴을 적용할 수 있습니다.

interface Person {
  name: string;
  placeOfBirth?: string;
  dateOfBirth?: Date;
}

두 속성을 하나의 객체로 묶어서 관리하면, null을 거를 수 있는 패턴이기 때문에 좀 더 나은 모델링입니다.

interface Person {
  name: string;
  birth?: {
    place: string;
    date: Date;
  };
}

API 요청과 같이 타입의 구조를 컨트롤 할 수 없는 구조에 있다면, interface로 구성된 union type으로 만들 수 있습니다.

interface Name {
  name: string;
}

interface PersonWithBirth extends Name {
  place: string;
  date: Date;
}

type Person = Name | PersonWithBirth;

Union Type으로 구성된 속성을 가지는 Interface를 만드는 것보다, Interface로 구성된 Union Type으로 타입을 정의하는 방법에 대해 알아봤습니다. Interface의 여러 속성이 union type으로 구성되면 속성 간 관계과 모호해 질 수 있어 종종 문제가 될 수 있습니다.

Restricting access with interfaces and write reusable code

다음과 같이 지도에 유저 또는 회사의 위치와 이름을 표기하는 간단한 맵 애플리케이션을 만들며 인터페이스를 사용하는 간단한 패턴을 알아보겠습니다.

위 앱을 만들기 위해서 유저, 회사, 지도를 추상화 할 클래스를 만들고자 하며, 세 파일을 index.ts에 import 할 것입니다.

index.ts
      |__CustomMap.ts
      |__User.ts
      |__Company.ts

유저와 회사에 대한 정보를 맵에 추가하기 위해 아래와 같이 유저, 회사 데이터를 추가하는 맵 클래스를 생성할 수 있을 것입니다.

export class CustomMap {
  private googleMap: google.maps.Map;

  constructor(elId: string) {
    this.googleMap = new google.maps.Map(document.getElementById(elId), {
      zoom: 1,
      center: {
        lat: 0,
        lng: 0
      }
    });
  }

  addUserMarker(user: User): void {
    new google.maps.Marker({
      map: this.googleMap,
      position: {
        lat: user.location.lat,
        lng: user.location.lng
      }
    });
  }

  addCompanyMarker(company: Company): void {
    new google.maps.Marker({
      map: this.googleMap,
      position: {
        lat: company.location.lat,
        lng: company.location.lng
      }
    });
  }
}

위 코드를 보면 addUserMarker와 addCompanyMarker 메소드가 매우 유사한 것을 알 수 있습니다. 코드 중복이 무조건 나쁜것만은 아니라고 생각하지만, 유저, 회사외에 공원, 학교, 마트 등 장소가 추가될 때마다 메소드를 생성하는 것은 좋지 않은 것 같습니다. 그래서 단순히 장소를 추가하는 메소드를 아래와 같이 만들어 리팩토링 할 수 있습니다.

export interface Mappable {
  location: {
    lat: number,
    lng: number,
  }
}

// ... 생략 ... //

addMarker(mappable: Mappable): void {
  new google.maps.Marker({
    map: this.googleMap,
    position: {
      lat: mappable.location.lat,
      lng: mappable.location.lng,
    }
  });
}

위 코드에서 Mappable이라는 interface로 타입을 제한했습니다. 이로 인해서, CustomMap 클래스는 어떠한 장소가 addMarker 메소드를 이용해 지도에 마커를 추가하려면 Mappable이라는 조건을 충적해야 한다고 제한을 둘 수 있습니다. 이러한 패턴은 일반적으로 자주 사용되고 있습니다.

타입스크립트의 가장 큰 특징 중 하나는 클래스와의 상호작용입니다. 일반적으로 타입스크립트 프로젝트에서 클래스를 사용할 때 상단에 interface를 명시합니다. 그리고 이 interface는 해당 클래스를 이용하기 위해 무엇을 해야하는지 알려줍니다. 이를 통해 코드 재사용성을 높이고 클래스 간 의존성을 낮출 수 있습니다.

Reference

typescriptlang.org