HEROPY
Tech
한눈에 보는 타입스크립트(updated)
{{ scrollPercentage }}%

한눈에 보는 타입스크립트(updated)

typescript

변경사항

2020년 2월

  • 다음 파트들을 추가했습니다.
    • keyof <인터페이스(Interface)/인덱싱 가능 타입(Indexable Types)>
    • 타입 별칭(Type Aliases)
  • 일부 내용과 오타 등을 수정했습니다.

2020년 3월

  • 다음의 파트들을 추가했습니다.
    • 알 수 없는 타입(Unknown) <타입 기본(Types)/타입 선언>
    • 인터섹션(Intersection) <타입 기본(Types)/타입 선언>
    • 함수 타입 <인터페이스(Interface)>
    • 클래스 타입 <인터페이스(Interface)>
    • 인터페이스 확장 <인터페이스(Interface)>
    • 함수
    • this <함수>
    • 명시적 this <함수>
    • 오버로드(Overloads) <함수>
  • 목차 흐름을 위해 ‘인덱스 시그니처(Index signature)’ 파트 제목을 삭제했습니다.(내용은 삭제하지 않았습니다)
  • 일부 내용과 오타 등을 수정했습니다.

2020년 4월

  • 다음의 파트들을 추가했습니다.
    • TS Node <개발환경>
    • 모듈
    • 내보내기(export)와 가져오기(import) <모듈>
    • 모듈의 타입 선언(Ambient module declaration) <모듈>
    • Definitely Typed(@types) <모듈>
    • typeRoots와 types 옵션 <모듈>
  • 컴파일 옵션에 대한 여러 링크를 추가했습니다.
  • 일부 내용과 오타 등을 수정했습니다.

2020년 6월

  • 다음의 파트들을 추가했습니다.
    • 제약 조건(Constraints) <제네릭(Generic)>
    • 조건부 타입(Conditional Types) <제네릭(Generic)>
    • infer <제네릭(Generic)>
    • Partial <TS 유틸리티 타입>
    • Required <TS 유틸리티 타입>
    • Readonly <TS 유틸리티 타입>
    • Record <TS 유틸리티 타입>
    • Pick <TS 유틸리티 타입>
    • Omit <TS 유틸리티 타입>
    • Exclude <TS 유틸리티 타입>
    • Extract <TS 유틸리티 타입>
    • NonNullable <TS 유틸리티 타입>
    • Parameters <TS 유틸리티 타입>
    • ConstructorParameters <TS 유틸리티 타입>
    • ReturnType <TS 유틸리티 타입>
    • InstanceType <TS 유틸리티 타입>
    • ThisParameterType <TS 유틸리티 타입>
    • OmitThisParameter <TS 유틸리티 타입>
    • ThisType <TS 유틸리티 타입>
  • ‘타입 가드’ 파트에 in 연산자에 대한 내용을 추가했습니다.
  • 일부 내용과 오타 등을 수정했습니다.

타입스크립트 개요

타입스크립트(TypeScript)는 Microsoft에서 개발하고 유지/관리하는 Apache 라이센스가 부여된 오픈 소스입니다.
일반 자바스크립트로 컴파일되는 자바스크립트 Superset(상위 호환)으로 2012년 10월에 처음 릴리스 되었습니다.

왜 타입스크립트인가?

C#과 Java 같은 체계적이고 정제된 언어들에서 사용하는 강한 타입 시스템은 높은 가독성과 코드 품질 등을 제공할 수 있고 런타임이 아닌 컴파일 환경에서 에러가 발생해 치명적인 오류들을 더욱더 쉽게 잡아낼 수 있습니다.

반면 자바스크립트는 타입 시스템이 없는 동적 프로그래밍 언어로, 자바스크립트 변수는 문자열, 숫자, 불린 등 여러 타입의 값을 가질 수 있습니다.
이를 약한 타입 언어라고 표현할 수 있으며 비교적 유연하게 개발할 수 있는 환경을 제공하는 한편 런타임 환경에서 쉽게 에러가 발생할 수 있는 단점을 가집니다.

그리고 타입스크립트는 이러한 자바스크립트에 강한 타입 시스템을 적용해 대부분의 에러를 컴파일 환경에서 코드를 입력하는 동안 체크할 수 있습니다.

타입스크립트 사용법

자바스크립트가 .js 확장자를 가진 파일로 작성되는 것과 같이 타입스크립트는 .ts 확장자를 가진 파일로 작성할 수 있고, 작성 후 타입스크립트 컴파일러를 통해 자바스크립트 파일로 컴파일하여 사용하게 됩니다.

$ tsc sample.ts
# compiled to `sample.js`

타입스크립트의 기능

  • 크로스 플랫폼 지원: 자바스크립트가 실행되는 모든 플랫폼에서 사용할 수 있습니다.
  • 객체 지향 언어: 클래스, 인터페이스, 모듈 등의 강력한 기능을 제공하며, 순수한 객체 지향 코드를 작성할 수 있습니다.
  • 정적 타입: 정적 타입을 사용하기 때문에 코드를 입력하는 동안에 오류를 체크할 수 있습니다.(단 에디터 혹은 플러그인의 도움의 필요)
  • DOM 제어: 자바스크립트와 같이 DOM을 제어해 요소를 추가하거나 삭제할 수 있습니다.
  • 최신 ECMAScript 기능 지원: ES6 이상의 최신 자바스크립트 문법을 손쉽게 지원할 수 있습니다.

개발환경

VSCode와 WebStorm

VSCode(Visual Studio Code)WebStorm은 타입스크립트 지원 기능이 내장되어 있기 때문에 별도의 설정 없이도 타입스크립트 파일을(.ts, tsconfig.json 등) 인식할 수 있고 코드 검사, 빠른 수정, 실행 및 디버깅 등의 다양한 기능을 바로 사용할 수 있습니다.
단, 컴파일러는 포함되어 있지 않기 때문에 별도로 설치해야 합니다.(E.g. npm install typescript)

VSCode Error

Visual Studio Code

WebStorm Error

WebStorm

컴파일러 설치

tsc 명령을 사용하기 위해 다음과 같이 타입스크립트를 전역 설치할 수 있습니다.
타입스크립트 파일을 경로로 지정하면 해당 파일을 컴파일합니다.

$ npm install -g typescript
$ tsc --version
$ tsc ./src/index.ts

혹은, 단일 프로젝트에서만 사용하길 희망하는 경우 일반 지역 설치 후 npx tsc 명령으로 실행할 수도 있습니다.

$ npm install -D typescript
$ npx tsc --version
$ npx tsc ./src/index.ts

컴파일러 옵션

타입스크립트 컴파일을 위한 다양한 옵션을 지정할 수 있습니다.

$ tsc ./src/index.ts --watch --strict true --target ES6 --lib ES2015,DOM --module CommonJS

혹은 아래와 같이 tsconfig.json 파일로 옵션을 관리할 수 있습니다.
"include""exclude" 옵션을 같이 추가해, 컴파일에 포함할 경로제외할 경로를 설정할 수 있습니다.

VScode와 WebStorm을 사용하는 경우, tsconfig.json 파일을 프로젝트 루트 경로에 생성하면 에디터에 의해 구성 옵션이 분석됩니다.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES6",
    "lib": ["ES2015", "DOM"],
    "module": "CommonJS"
  },
  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}
$ tsc --watch

TypeScript Playground

https://www.typescriptlang.org/play/index.html

타입스크립트 공식 페이지에서 제공하는 REPL로, 작성한 내용이 컴파일러 옵션에 따라 어떻게 자바스크립트로 변환되는지 바로 확인할 수 있습니다.

typescript playground

Repl.it

https://repl.it/languages/typescript

파일과 디렉터리로 관리되는 타입스크립트 프로젝트를 손쉽게 구성할 수 있습니다.
간단한 프로젝트로 타입스크립트를 테스트하기 좋습니다.

repl.io - typescript

Parcel

타입스크립트를 로컬 환경에서 빠르게 테스트하고 싶다면 Parcel 번들러가 좋은 선택입니다.
다음과 같이 간단하게 프로젝트를 구성합니다.

$ mkdir typescript-test
$ cd typescript-test
$ npm init -y
$ npm install -D typescript parcel-bundler

프로젝트 구조

tsconfig.json 파일을 생성하고 원하는 옵션을 추가합니다.
다음은 예시입니다.

{
  "compilerOptions": {
    "strict": true
  },
  "exclude": [
    "node_modules"
  ]
}

main.ts 파일을 생성하고 원하는 타입스크립트 코드를 입력합니다.

function add(a: number, b: number) {
  return a + b;
}
const sum: number = add(1, 2);
console.log(sum);

index.html 파일을 생성하고 다음과 같이 .js가 아닌 .ts 파일을 연결합니다.
Parcel 번들러가 빌드시 자동으로 타입스크립트를 컴파일합니다.

<!doctype html>
<html>
<head>
  <meta charset="UTF-8">
  <title>TypeScript Test</title>
</head>
<body>
  <script src="main.ts"></script>
</body>
</html>

마지막으로 다음과 같이 진입 파일로 index.html를 지정하고 Parcel 번들러로 빌드합니다.

$ npx parcel index.html
# Server running at http://localhost:1234

TS Node

NodeJS 환경에서 테스트하고 싶다면 TS Node를 사용하세요.
다음과 같이 간단하게 프로젝트를 구성합니다.

$ mkdir typescript-test
$ cd typescript-test
$ npm init -y
$ npm install -D typescript @types/node ts-node

@types/node는 Node.js API를 위한 타입 선언 모듈입니다.
@types에 대한 자세한 내용은 ‘모듈’ 파트를 참고하세요.

프로젝트 구조

tsconfig.json 파일을 생성하고 원하는 옵션을 추가합니다.
다음은 예시입니다.

{
  "compilerOptions": {
    "strict": true,
    "module": "CommonJS"
  },
  "exclude": [
    "node_modules"
  ]
}

main.ts 파일을 생성하고 원하는 타입스크립트 코드를 입력합니다.

console.log('TypeScript on NodeJS!');

TS Node를 사용해 main.ts를 실행합니다.

$ npx ts-node main.js
# TypeScript on NodeJS!

타입 기본(Types)

타입 지정

타입스크립트는 일반 변수, 매개 변수(Parameter), 객체 속성(Property) 등에 : TYPE과 같은 형태로 타입을 지정할 수 있습니다.

function someFunc(a: TYPE_A, b: TYPE_B): TYPE_RETURN {
  return a + b;
}
let some: TYPE_SOME = someFunc(1, 2);

다음 예시를 보면,
add 함수의 매개 변수 abnumber 타입이어야 한다고 지정했고,
그렇게 실행된 함수의 반환 값은 숫자로 추론(Inference)되기 때문에 변수 sumnumber 타입이어야 한다고 지정했습니다.

function add(a: number, b: number) {
  return a + b;
}
const sum: number = add(1, 2);
console.log(sum); // 3

자바스크립트로 컴파일한 결과는 다음과 같습니다.

"use strict";
function add(a, b) {
  return a + b;
}
const sum = add(1, 2);
console.log(sum);

타입 에러

만약 다음과 같이 변수 sumnumber가 아닌 string 타입이어야 한다고 지정했다면, 컴파일조차 하지 않고 코드를 작성하는 시점에서 에러가 발생합니다.

function add(a: number, b: number) {
  return a + b;
}
const sum: string = add(1, 2);
console.log(sum);

Typescript error - TS2322

TS2322 Error

위 이미지에서 TS2322라는 에러 코드를 볼 수 있으며, 이를 검색하면 쉽게 에러 코드에 대한 정보를 얻을 수 있습니다.

Search Typescript error code

타입 선언

불린: Boolean

단순한 참(true)/거짓(false) 값을 나타냅니다.

let isBoolean: boolean;
let isDone: boolean = false;

숫자: Number

모든 부동 소수점 값을 사용할 수 있습니다.
ES6에 도입된 2진수 및 8진수 리터럴도 지원합니다.

let num: number;
let integer: number = 6;
let float: number = 3.14;
let hex: number = 0xf00d; // 61453
let binary: number = 0b1010; // 10
let octal: number = 0o744; // 484
let infinity: number = Infinity;
let nan: number = NaN;

문자열: String

문자열을 나타냅니다.
작은따옴표('), 큰따옴표(") 뿐만 아니라 ES6의 템플릿 문자열도 지원합니다.

let str: string;
let red: string = 'Red';
let green: string = "Green";
let myColor: string = `My color is ${red}.`;
let yourColor: string = 'Your color is' + green;

배열: Array

순차적으로 값을 가지는 일반 배열을 나타냅니다.
배열은 다음과 같이 두 가지 방법으로 타입을 선언할 수 있습니다.

// 문자열만 가지는 배열
let fruits: string[] = ['Apple', 'Banana', 'Mango'];
// Or
let fruits: Array<string> = ['Apple', 'Banana', 'Mango'];

// 숫자만 가지는 배열
let oneToSeven: number[] = [1, 2, 3, 4, 5, 6, 7];
// Or
let oneToSeven: Array<number> = [1, 2, 3, 4, 5, 6, 7];

유니언 타입(다중 타입)의 ‘문자열과 숫자를 동시에 가지는 배열’도 선언할 수 있습니다.

let array: (string | number)[] = ['Apple', 1, 2, 'Banana', 'Mango', 3];
// Or
let array: Array<string | number> = ['Apple', 1, 2, 'Banana', 'Mango', 3];

배열이 가지는 항목의 값을 단언할 수 없다면 any를 사용할 수 있습니다.

let someArr: any[] = [0, 1, {}, [], 'str', false];

인터페이스(Interface)나 커스텀 타입(Type)을 사용할 수도 있습니다.

interface IUser {
  name: string,
  age: number,
  isValid: boolean
}
let userArr: IUser[] = [
  {
    name: 'Neo',
    age: 85,
    isValid: true
  },
  {
    name: 'Lewis',
    age: 52,
    isValid: false
  },
  {
    name: 'Evan',
    age: 36,
    isValid: true
  }
];

유용하진 않지만, 다음과 같이 특정한 값으로 타입을 대신해 작성할 수도 있습니다.

let array = 10[];
array = [10];
array.push(10);
array.push(11); // Error - TS2345

읽기 전용 배열을 생성할 수도 있습니다.
readonly 키워드나 ReadonlyArray 타입을 사용하면 됩니다.

let arrA: readonly number[] = [1, 2, 3, 4];
let arrB: ReadonlyArray<number> = [0, 9, 8, 7];

arrA[0] = 123; // Error - TS2542: Index signature in type 'readonly number[]' only permits reading.
arrA.push(123); // Error - TS2339: Property 'push' does not exist on type 'readonly number[]'.

arrB[0] = 123; // Error - TS2542: Index signature in type 'readonly number[]' only permits reading.
arrB.push(123); // Error - TS2339: Property 'push' does not exist on type 'readonly number[]'.

튜플: Tuple

Tuple 타입은 배열과 매우 유사합니다.
차이점이라면 정해진 타입의 고정된 길이(length) 배열을 표현합니다.

let tuple: [string, number];
tuple = ['a', 1];
tuple = ['a', 1, 2]; // Error - TS2322
tuple = [1, 'a']; // Error - TS2322

다음과 같이 데이터를 개별 변수로 지정하지 않고, 단일 Tuple 타입으로 지정해 사용할 수 있습니다.

// Variables
let userId: number = 1234;
let userName: string = 'HEROPY';
let isValid: boolean = true;

// Tuple
let user: [number, string, boolean] = [1234, 'HEROPY', true];
console.log(user[0]); // 1234
console.log(user[1]); // 'HEROPY'
console.log(user[2]); // true

나아가 위 방식을 활용해 다음과 같은 Tuple 타입의 배열(2차원 배열)을 사용할 수 있습니다.

let users: [number, string, boolean][];
// Or
// let users: Array<[number, string, boolean]>;

users = [[1, 'Neo', true], [2, 'Evan', false], [3, 'Lewis', true]];

역시 값으로 타입을 대신할 수도 있습니다.

let tuple: [1, number];
tuple = [1, 2];
tuple = [1, 3];
tuple = [2, 3]; // Error - TS2322: Type '2' is not assignable to type '1'.

Tuple은 정해진 타입의 고정된 길이 배열을 표현하지만, 이는 할당(Assign)에 국한됩니다.
.push().splice() 등을 통해 값을 넣는 행위는 막을 수 없습니다.

let tuple: [string, number];
tuple = ['a', 1];
tuple = ['b', 2];
tuple.push(3);
console.log(tuple); // ['b', 2, 3];
tuple.push(true); // Error - TS2345: Argument of type 'true' is not assignable to parameter of type 'string | number'.

배열에서 사용한 것과 같이 readonly 키워드를 사용해 읽기 전용 튜플을 생성할 수도 있습니다.

let a: readonly [string, number] = ['Hello', 123];
a[0] = 'World'; // Error - TS2540: Cannot assign to '0' because it is a read-only property.

열거형: Enum

Enum은 숫자 혹은 문자열 값 집합에 이름(Member)을 부여할 수 있는 타입으로, 값의 종류가 일정한 범위로 정해져 있는 경우 유용합니다.

기본적으로 0부터 시작하며 값은 1씩 증가합니다.

enum Week {
  Sun,
  Mon,
  Tue,
  Wed,
  Thu,
  Fri,
  Sat
}

enum example

WebStorm을 통해 보여지는 enum

수동으로 값을 변경할 수 있으며, 값을 변경한 부분부터 다시 1씩 증가합니다.

enum example

Enum 타입의 재미있는 부분은 역방향 매핑(Reverse Mapping)을 지원한다는 것입니다.
이것은 열거된 멤버(Sun, Mon 같은)로 값에, 값으로 멤버에 접근할 수 있다는 것을 의미합니다.

Week를 콘솔로 출력합니다.

enum Week {
  // ...
}
console.log(Week);
console.log(Week.Sun); // 0
console.log(Week['Sun']); // 0
console.log(Week[0]); // 'Sun'

enum reverse mapping

역방향 매핑(Reverse Mapping)

추가로, Enum은 숫자 값 열거뿐만아니라 다음과 같이 문자열 값으로 초기화할 수 있습니다.
이 방법은 역방향 매핑(Reverse Mapping)을 지원하지 않으며 개별적으로 초기화해야 하는 단점이 있습니다.

enum Color {
  Red = 'red',
  Green = 'green',
  Blue = 'blue'
}
console.log(Color.Red); // red
console.log(Color['Green']); // green

모든 타입: Any

Any는 모든 타입을 의미합니다.
따라서 일반적인 자바스크립트 변수와 동일하게 어떤 타입의 값도 할당할 수 있습니다.
외부 자원을 활용해 개발할 때 불가피하게 타입을 단언할 수 없는 경우, 유용할 수 있습니다.

let any: any = 123;
any = 'Hello world';
any = {};
any = null;

다양한 값을 포함하는 배열을 나타낼 때 사용할 수도 있습니다.

const list: any[] = [1, true, 'Anything!'];

강한 타입 시스템의 장점을 유지하기 위해 Any 사용을 엄격하게 금지하려면, 컴파일 옵션 "noImplicitAny": true를 통해 Any 사용 시 에러를 발생시킬 수 있습니다.

알 수 없는 타입: Unknown

Any와 같이 최상위 타입인 Unknown은 알 수 없는 타입을 의미합니다.
Any와 같이 Unknown에는 어떤 타입의 값도 할당할 수 있지만, Unknown을 다른 타입에는 할당할 수 없습니다.

일반적인 경우 Unknown은 타입 단언(Assertions)이나 타입 가드(Guards)를 필요로 합니다.
타입 단언이나 가드에 대한 내용은 다른 파트에서 정리합니다.

let a: any = 123;
let u: unknown = 123;

let v1: boolean = a; // 모든 타입(any)은 어디든 할당할 수 있습니다.
let v2: number = u; // 알 수 없는 타입(unknown)은 모든 타입(any)을 제외한 다른 타입에 할당할 수 없습니다.
let v3: any = u; // OK!
let v4: number = u as number; // 타입을 단언하면 할당할 수 있습니다.

다양한 타입을 반환할 수 있는 API에서 유용할 수 있습니다.

Unknown 보단 좀 더 명확한 타입을 사용하는 것이 좋습니다.

type Result = {
  success: true,
  value: unknown
} | {
  success: false,
  error: Error
}

export default function getItems(user: IUser): Result {
  // Some logic...
  if (id.isValid) {
    return {
      success: true,
      value: ['Apple', 'Banana']
    };
  } else {
    return {
      success: false,
      error: new Error('Invalid user.')
    }
  }
}

객체: Object

기본적으로 typeof 연산자가 "object"로 반환하는 모든 타입을 나타냅니다.

컴파일러 옵션에서 엄격한 타입 검사(strict)를 true로 설정하면, null은 포함하지 않습니다.

let obj: object = {};
let arr: object = [];
let func: object = function () {};
let nullValue: object = null;
let date: object = new Date();
// ...

여러 타입의 상위 타입이기 때문에 그다지 유용하지 않습니다.
보다 정확하게 타입 지정을 하기 위해 다음과 같이 객체 속성(Properties)들에 대한 타입을 개별적으로 지정할 수 있습니다.

let userA: { name: string, age: number } = {
  name: 'HEROPY',
  age: 123
};

let userB: { name: string, age: number } = {
  name: 'HEROPY',
  age: false, // Error
  email: '[email protected]' // Error
};

반복적인 사용을 원하는 경우, interfacetype을 사용하는 것을 추천합니다.

interface IUser {
  name: string,
  age: number
}

let userA: IUser = {
  name: 'HEROPY',
  age: 123
};

let userB: IUser = {
  name: 'HEROPY',
  age: false, // Error
  email: '[email protected]' // Error
};

Null과 Undefined

기본적으로 Null과 Undefined는 모든 타입의 하위 타입으로, 다음과 같이 각 타입에 할당할 수 있습니다.
심지어 서로의 타입에도 할당 가능합니다.

let num: number = undefined;
let str: string = null;
let obj: { a: 1, b: false } = undefined;
let arr: any[] = null;
let und: undefined = null;
let nul: null = undefined;
let voi: void = null;
// ...

이는 컴파일 옵션 "strictNullChecks": true을 통해 엄격하게 Null과 Undefined 서로의 타입까지 더 이상 할당할 수 없게 합니다.
단, Void에는 Undefined를 할당할 수 있습니다.

let voi: void = undefined; // ok

Void

Void는 일반적으로 값을 반환하지 않는 함수에서 사용합니다.
: void 위치는 함수가 반환 타입을 명시하는 곳입니다.

function hello(msg: string): void {
  console.log(`Hello ${msg}`);
}

값을 반환하지 않는 함수는 실제로는 undefined를 반환합니다.

function hello(msg: string): void {
  console.log(`Hello ${msg}`);
}
const hi: void = hello('world'); // Hello world
console.log(hi); // undefined
// Error - TS2355: A function whose declared type is neither 'void' nor 'any' must return a value.
function hello(msg: string): undefined {
  console.log(`Hello ${msg}`);
}

Never

Never은 절대 발생하지 않을 값을 나타내며, 어떠한 타입도 적용할 수 없습니다.

function error(message: string): never {
  throw new Error(message);
}

보통 다음과 같이 빈 배열을 타입으로 잘못 선언한 경우, Never를 볼 수 있습니다.

const never: [] = [];
never.push(3); // Error - TS2345: Argument of type '3' is not assignable to parameter of type 'never'.

유니언(Union)

2개 이상의 타입을 허용하는 경우, 이를 유니언(Union)이라고 합니다.
|(vertical bar)를 통해 타입을 구분하며, ()는 선택 사항입니다.

let union: (string | number);
union = 'Hello type!';
union = 123;
union = false; // Error - TS2322: Type 'false' is not assignable to type 'string | number'.

인터섹션(Intersection)

&(ampersand)를 사용해 2개 이상의 타입을 조합하는 경우, 이를 인터섹션(Intersection)이라고 합니다.
인터섹션은 새로운 타입을 생성하지 않고 기존의 타입들을 조합할 수 있기 때문에 유용하지만, 자주 사용되는 방법은 아닙니다.

위에서 살펴본 유니언을 마치 ‘또는(Or)’과 같이 이해할 수 있다면, 인터섹션은 ‘그리고(And)’와 같이 이해할 수 있습니다.

// 기존 타입들이 조합 가능하다면 인터섹션을 활용할 수 있습니다.
interface IUser {
  name: string,
  age: number
}
interface IValidation {
  isValid: boolean
}
const heropy: IUser = {
  name: 'Heropy',
  age: 36,
  isValid: true // Error -  TS2322: Type '{ name: string; age: number; isValid: boolean; }' is not assignable to type 'IUser'.
};
const neo: IUser & IValidation = {
  name: 'Neo',
  age: 85,
  isValid: true
};

// 혹은 기존 타입(IUser, IValidation)과 비슷하지만, 정확히 일치하는 타입이 없다면 새로운 타입을 생성해야 합니다.
interface IUserNew {
  name: string,
  age: number,
  isValid: boolean
}
const evan: IUserNew = {
  name: 'Evan',
  age: 36,
  isValid: false
};

함수(Function)

화살표 함수를 이용해 타입을 지정할 수 있습니다.
인수의 타입과 반환 값의 타입을 입력합니다.

// myFunc는 2개의 숫자 타입 인수를 가지고, 숫자 타입을 반환하는 함수.
let myFunc: (arg1: number, arg2: number) => number;
myFunc = function (x, y) {
  return x + y;
};

// 인수가 없고, 반환도 없는 경우.
let yourFunc: () => void;
yourFunc = function () {
  console.log('Hello world~');
};

타입 추론(Inference)

명시적으로 타입 선언이 되어있지 않은 경우, 타입스크립트는 타입을 추론해 제공합니다.
개념은 매우 단순합니다.

[추론]: 어떠한 판단을 근거로 삼아 다른 판단을 이끌어 냄.

let num = 12;
num = 'Hello type!'; // TS2322: Type '"Hello type!"' is not assignable to type 'number'.

변수 num을 초기화하면서 숫자 12를 할당해 Number 타입으로 추론되었고, 따라서 'Hello type!'이라는 String 타입의 값은 할당할 수 없기 때문에 에러가 발생합니다.

이렇게 타입스크립트가 타입을 추론하는 경우는 다음과 같습니다.

  • 초기화된 변수
  • 기본값이 설정된 매개 변수
  • 반환 값이 있는 함수
// 초기화된 변수 `num`
let num = 12;

// 기본값이 설정된 매개 변수 `b`
function add(a: number, b: number = 2): number {
  // 반환 값(`a + b`)이 있는 함수
  return a + b;
}

타입 추론이 엄격하지 않은 타입 선언을 의미하는 것은 아닙니다.
따라서 이를 활용해 모든 곳에 타입을 명시할 필요는 없으며, 많은 경우 더 좋은 코드 가독성을 제공할 수 있습니다.

타입 단언(Assertions)

타입스크립트가 타입 추론을 통해 판단할 수 있는 타입의 범주를 넘는 경우, 더 이상 추론하지 않도록 지시할 수 있습니다.
이를 ‘타입 단언’이라고 하며, 이는 프로그래머가 타입스크립트보다 타입에 대해 더 잘 이해하고 있는 상황을 의미합니다.

[단언]: 주저하지 아니하고 딱 잘라 말함.

다음 예제를 살펴봅시다.

함수의 매개 변수 val은 유니언 타입으로 문자열(String)이거나 숫자(Number)일 수 있습니다.
그리고 매개 변수 isNumber는 불린(Boolean)이며, 이름을 통해 숫자 여부를 확인하는 값이라는 것을 (우리는) 추론할 수 있습니다.
따라서 우리는 isNumbertrue일 경우 val은 숫자일 것이고, 이에 toFixed를 사용할 수 있음을 확실히 알 수 있습니다.
하지만 타입스크립트는 ‘isNumber’라는 이름만으로 위 내용을 추론할 수 없기 때문에 “val이 문자열인 경우 toFixed를 사용할 수 없다”고 (컴파일 단계에서) 다음과 같은 에러를 반환합니다.

function someFunc(val: string | number, isNumber: boolean) {
  // some logics
  if (isNumber) {
    val.toFixed(2); // Error - TS2339: ... Property 'toFixed' does not exist on type 'string'.
  }
}

따라서 우리는 isNumbertrue일 때 val이 숫자임을 다음과 같이 2가지 방식으로 단언할 수 있습니다.
두 번째 방식(<number>val)은 JSX를 사용하는 경우 특정 구문 파싱에서 문제가 발생할 수 있으며, 결과적으로 .tsx 파일에서는 전혀 사용할 수 없습니다.

function someFunc(val: string | number, isNumber: boolean) {
  // some logics
  if (isNumber) {
    // 1. 변수 as 타입
    (val as number).toFixed(2);
    // Or
    // 2. <타입>변수
    // (<number>val).toFixed(2);
  }
}

타입 단언은 마치 프로그래머가 타입스크립트에게 “나는 알고 있으니까 나를 믿어!”라고 알려주는 것과 같습니다.

Non-null 단언 연산자

!를 사용하는 Non-null 단언 연산자(Non-null assertion operator)를 통해 피연산자가 Nullish(null이나 undefined) 값이 아님을 단언할 수 있는데, 변수나 속성에서 간단하게 사용할 수 있기 때문에 유용합니다.

다음 예제 중 fnA 함수를 살펴보면, 매개 변수 x는 함수 내에서 toFixed를 사용하는 숫자 타입으로 처리되지만 null이나 undefined일 수 있기 때문에 에러가 발생합니다.
이를 타입 단언이나 if 조건문으로 해결할 수도 있지만, 마지막 함수와 같이 !를 사용하는 Non-null 단언 연산자를 이용해 간단하게 정리할 수 있습니다.

// Error - TS2533: Object is possibly 'null' or 'undefined'.
function fnA(x: number | null | undefined) {
  return x.toFixed(2);
}

// if statement
function fnD(x: number | null | undefined) {
  if (x) {
    return x.toFixed(2);
  }
}

// Type assertion
function fnB(x: number | null | undefined) {
  return (x as number).toFixed(2);
}
function fnC(x: number | null | undefined) {
  return (<number>x).toFixed(2);
}

// Non-null assertion operator
function fnE(x: number | null | undefined) {
  return x!.toFixed(2);
}

특히 컴파일 환경에서 체크하기 어려운 DOM 사용에서 유용합니다.
물론 일반적인 타입 단언을 사용할 수도 있습니다.

// Error - TS2531: Object is possibly 'null'.
document.querySelector('.menu-item').innerHTML;

// Type assertion
(document.querySelector('.menu-item') as HTMLDivElement).innerHTML;
(<HTMLDivElement>document.querySelector('.menu-item')).innerHTML;

// Non-null assertion operator
document.querySelector('.menu-item')!.innerHTML;

타입 가드(Guards)

다음 예제와 같이 val의 타입을 매번 보장하기 위해 타입 단언을 여러 번 사용하게 되는 경우가 있습니다.

function someFunc(val: string | number, isNumber: boolean) {
  if (isNumber) {
    (val as number).toFixed(2);
    isNaN(val as number);
  } else {
    (val as string).split('');
    (val as string).toUpperCase();
    (val as string).length;
  }
}

이 경우 타입 가드를 제공하면 타입스크립트가 추론 가능한 특정 범위(scope)에서 타입을 보장할 수 있습니다.
타입 가드는 NAME is TYPE 형태의 타입 술부(Predicate)를 반환 타입으로 명시한 함수입니다.
다음 예제에서 타입 술부는 val is number입니다.
타입 단언이 없어지니 훨씬 깔끔합니다.

[술부]: 주어의 상태, 성질 따위를 서술하는 말.

// 타입 가드
function isNumber(val: string | number): val is number {
  return typeof val === 'number';
}

function someFunc(val: string | number) {
  if (isNumber(val)) {
    val.toFixed(2);
    isNaN(val);
  } else {
    val.split('');
    val.toUpperCase();
    val.length;
  }
}

위 방식뿐만 아니라 제공 가능한 타입 가드가 더 있습니다.
typeof, in 그리고 instanceof 연산자를 직접 사용하는 타입 가드입니다.
비교적 단순한 로직에서 추천되는 방식입니다.

typeof 연산자는 number, string, boolean, 그리고 symbol만 타입 가드로 인식할 수 있습니다.
in 연산자의 우변 객체(val)는 any 타입이어야 합니다.

// 기존 예제와 같이 `isNumber`를 제공(추상화)하지 않아도 `typeof` 연산자를 직접 사용하면 타입 가드로 동작합니다.
// https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/typeof
function someFuncTypeof(val: string | number) {
  if (typeof val === 'number') {
    val.toFixed(2);
    isNaN(val);
  } else {
    val.split('');
    val.toUpperCase();
    val.length;
  }
}

// 별도의 추상화 없이 `in` 연산자를 사용해 타입 가드를 제공합니다.
// https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/in
function someFuncIn(val: any) {
  if ('toFixed' in val) {
    val.toFixed(2);
    isNaN(val);
  } else if ('split' in val) {
    val.split('');
    val.toUpperCase();
    val.length;
  }
}

// 역시 별도의 추상화 없이 `instanceof` 연산자를 사용해 타입 가드를 제공합니다.
// https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/instanceof
class Cat {
  meow() {}
}
class Dog {
  woof() {}
}
function sounds(ani: Cat | Dog) {
  if (ani instanceof Cat) {
    ani.meow();
  } else {
    ani.woof();
  }
}

인터페이스(interface)

인터페이스(Interface)는 타입스크립트 여러 객체를 정의하는 일종의 규칙이며 구조입니다.
다음과 같이 interface 키워드와 함께 사용합니다.

‘IUser’에서 ‘I’는 Interface를 의미하는 별칭으로 사용했습니다.

interface IUser {
  name: string,
  age: number,
  isAdult: boolean
}

let user1: IUser = {
  name: 'Neo',
  age: 123,
  isAdult: true
};

// Error - TS2741: Property 'isAdult' is missing in type '{ name: string; age: number; }' but required in type 'IUser'.
let user2: IUser = {
  name: 'Evan',
  age: 456
};

:(colon), ,(comma) 혹은 기호를 사용하지 않을 수 있습니다.

interface IUser {
  name: string,
  age: number
}
// Or
interface IUser {
  name: string;
  age: number;
}
// Or
interface IUser {
  name: string
  age: number
}

다음과 같이 속성에 ?를 사용하면 선택적 속성으로 정의할 수 있습니다.
선택적 속성(Optional properties)에 대해선 Optional 파트에서 따로 설명하지만, 간단하게 표현하면 ‘필수가 아닌 속성으로 정의’하는 방법을 말합니다.

interface IUser {
  name: string,
  age: number,
  isAdult?: boolean // Optional property
}

// `isAdult`를 초기화하지 않아도 에러가 발생하지 않습니다.
let user: IUser = {
  name: 'Neo',
  age: 123
};

읽기 전용 속성(Readonly properties)

readonly 키워드를 사용하면 초기화된 값을 유지해야 하는 읽기 전용 속성을 정의할 수 있습니다.

interface IUser {
  readonly name: string,
  age: number
}

// 초기화
let user: IUser = {
  name: 'Neo',
  age: 36
};

user.age = 85; // Ok
user.name = 'Evan'; // Error - TS2540: Cannot assign to 'name' because it is a read-only property.

만약 모든 속성이 readonly일 경우, 유틸리티(Utility)나 단언(Assertion) 타입을 활용할 수 있습니다.

// All readonly properties
interface IUser {
  readonly name: string,
  readonly age: number
}
let user: IUser = {
  name: 'Neo',
  age: 36
};
user.age = 85; // Error
user.name = 'Evan'; // Error


// Readonly Utility
interface IUser {
  name: string,
  age: number
}
let user: Readonly<IUser> = {
  name: 'Neo',
  age: 36
};
user.age = 85; // Error
user.name = 'Evan'; // Error


// Type assertion
let user = {
  name: 'Neo',
  age: 36
} as const;
user.age = 85; // Error
user.name = 'Evan'; // Error

함수 타입

함수 타입을 인터페이스로 정의하는 경우, 호출 시그니처(Call signature)라는 것을 사용합니다.
호출 시그니처는 다음과 같이 함수의 매개 변수(parameter)와 반환 타입을 지정합니다.

interface IName {
  (PARAMETER: PARAM_TYPE): RETURN_TYPE // Call signature
}

간단한 예시를 살펴봅시다.
인터페이스 IGetUser를 통해 함수 타입을 정의했으며, 이는 name 매개 변수를 하나 가지며(이름이 일치할 필요는 없습니다), IUser 타입을 반환해야 합니다.

interface IUser {
  name: string
}
interface IGetUser {
  (name: string): IUser
}

// 매개 변수 이름이 인터페이스와 일치할 필요가 없습니다.
// 또한 타입 추론을 통해 매개 변수를 순서에 맞게 암시적 타입으로 제공할 수 있습니다.
const getUser: IGetUser = function (n) { // n is name: string
  // Find user logic..
  // ...
  return user;
};
getUser('Heropy');

클래스 타입

인터페이스로 클래스를 정의하는 경우, implements 키워드를 사용합니다.

interface IUser {
  name: string,
  getName(): string
}

class User implements IUser {
  constructor(public name: string) {}
  getName() {
    return this.name;
  }
}

const neo = new User('Neo');
neo.getName(); // Neo

기본적인 사용법은 어렵지 않습니다.
그런데 만약 정의한 클래스를 인수로 사용하는 경우 다음과 같은 문제가 발생할 수 있습니다.
다음 예제에서 인터페이스 ICat은 호출 가능한 구조가 아니기 때문입니다.

interface ICat {
  name: string
}

class Cat implements ICat {
  constructor(public name: string) {}
}

function makeKitten(c: ICat, n: string) {
  return new c(n); // Error - TS2351: This expression is not constructable. Type 'ICat' has no construct signatures.
}
const kitten = makeKitten(Cat, 'Lucy');
console.log(kitten);

이를 위해 구성 시그니처(Construct signature)를 제공할 수 있습니다.
구성 시그니처는 위에서 살펴본 호출 시그니처와 비슷하지만, new 키워드를 사용해야 합니다.

interface IName {
  new (PARAMETER: PARAM_TYPE): RETURN_TYPE // Construct signature
}

위에서 봤던 예제를 다음과 같이 수정합니다.
ICatConstructor라는 구성 시그니처를 가지는 호출 가능한 인터페이스를 정의하면, 문제없이 동작하는 것을 확인할 수 있습니다.

interface ICat {
  name: string
}
interface ICatConstructor {
  new (name: string): ICat;
}

class Cat implements ICat {
  constructor(public name: string) {}
}

function makeKitten(c: ICatConstructor, n: string) {
  return new c(n); // ok
}
const kitten = makeKitten(Cat, 'Lucy');
console.log(kitten);

비슷하지만 좀 더 재미있는 예제를 준비했습니다.
에러가 발생하는 부분을 확인하고 내용을 이해했다면 충분합니다.

interface IFullName {
  firstName: string,
  lastName: string
}
interface IFullNameConstructor {
  new(firstName: string): IFullName; // Construct signature
}


function makeSon(c: IFullNameConstructor, firstName: string) {
  return new c(firstName);
}
function getFullName(son: IFullName) {
  return `${son.firstName} ${son.lastName}`;
}


// Anderson family
class Anderson implements IFullName {
  public lastName: string;
  constructor (public firstName: string) {
    this.lastName = 'Anderson';
  }
}
const tomas = makeSon(Anderson, 'Tomas');
const jack = makeSon(Anderson, 'Jack');
getFullName(tomas); // Tomas Anderson
getFullName(jack); // Jack Anderson


// Smith family?
class Smith implements IFullName {
  public lastName: string;
  constructor (public firstName: string, agentCode: number) {
    this.lastName = `Smith ${agentCode}`;
  }
}
const smith = makeSon(Smith, 7); // Error - TS2345: Argument of type 'typeof Smith' is not assignable to parameter of type 'IFullNameConstructor'.
getFullName(smith);

인덱싱 가능 타입(Indexable types)

우리는 인터페이스를 통해 특정 속성(메소드 등)의 타입을 정의할 순 있지만, 수많은 속성을 가지거나 단언할 수 없는 임의의 속성이 포함되는 구조에서는 기존의 방식만으론 한계가 있습니다. 이런 상황에서 유용한 인덱스 시그니처(Index signature)에 대해서 살펴봅시다.

arr[2]와 같이 ‘숫자’로 인덱싱하거나 obj['name']과 같이 ‘문자’로 인덱싱하는, 인덱싱 가능 타입(Indexable types)들이 있습니다.
이런 인덱싱 가능 타입들을 정의하는 인터페이스는 인덱스 시그니처(Index signature)라는 것을 가질 수 있습니다.
인덱스 시그니처는 다음 구조와 같이, 인덱싱에 사용할 인덱서(Indexer)의 이름과 타입 그리고 인덱싱 결과의 반환 값을 지정합니다.
인덱서의 타입은 stringnumber만 지정할 수 있습니다.

interface INAME {
  [INDEXER_NAME: INDEXER_TYPE]: RETURN_TYPE // Index signature
}

배열(객체)에서 위치를 가리키는 숫자(문자)를 인덱스(index)라고 하며, 각 배열 요소(객체 속성)에 접근하기 위하여 인덱스를 사용하는 것을 인덱싱(indexing)이라고 합니다.(배열을 구성하는 각각의 값은 배열 요소(element)라고 합니다)

이해를 돕기 위해 다음 예제를 살펴보면,
인터페이스 IItem은 인덱스 시그니처를 가지고 있으며, 그 IItem을 타입(인터페이스)으로 하는 item이 있고, 그 itemitem[0]이나 item[1]과 같이 숫자로 인덱싱할 때 반환되는 값은 'a'b' 같은 문자여야 합니다.
itemitem['0']과 같이 문자로 인덱싱하는 경우 에러가 발생합니다.

interface IItem {
  [itemIndex: number]: string // Index signature
}
let item: IItem = ['a', 'b', 'c']; // Indexable type
console.log(item[0]); // 'a' is string.
console.log(item[1]); // 'b' is string.
console.log(item['0']); // Error - TS7015: Element implicitly has an 'any' type because index expression is not of type 'number'.

참고로 인덱싱 결과의 반환 타입으로 유니온을 사용하면 다음과 같이 활용할 수 있습니다.

interface IItem {
  [itemIndex: number]: string | boolean | number[]
}
let item: IItem = ['Hello', false, [1, 2, 3]];
console.log(item[0]); // Hello
console.log(item[1]); // false
console.log(item[2]); // [1, 2, 3]

이번에는 문자로 인덱싱하는 예제를 살펴봅시다.
인터페이스 IUser는 인덱스 시그니처를 가지고 있으며, 그 IUser를 타입(인터페이스)로 하는 user가 있고, 그 useruser['name'], user['email'] 또는 user['isValid']와 같이 문자로 인덱싱할 때 반환되는 값은 'Neo''[email protected]' 같은 문자 혹은 true 같은 불린이어야 합니다.
또한 user[0]과 같은 숫자로 인덱싱하는 경우나 user['0']과 같이 문자로 인덱싱하는 경우 모두 인덱싱 전에 숫자가 문자열로 변환되기 때문에 다음과 같이 값을 반환할 수 있습니다.

interface IUser {
  [userProp: string]: string | boolean
}
let user: IUser = {
  name: 'Neo',
  email: '[email protected]',
  isValid: true,
  0: false
};
console.log(user['name']); // 'Neo' is string.
console.log(user['email']); // '[email protected]' is string.
console.log(user['isValid']); // true is boolean.
console.log(user[0]); // false is boolean
console.log(user[1]); // undefined
console.log(user['0']); // false is boolean

인덱스 시그니처를 사용하면 다음 예제와 같이 인터페이스에 정의되지 않은 속성들을 사용할 때 유용합니다.
단, 해당 속성이 인덱스 시그니처에 정의된 반환 값을 가져야 함에 주의해야 합니다.
다음 예제에서 isAdult 속성은 정의된 string이나 number 타입을 반환하지 않지 않기 때문에 에러가 발생합니다.

interface IUser {
  [userProp: string]: string | number
  name: string,
  age: number
}
let user: IUser = {
  name: 'Neo',
  age: 123,
  email: '[email protected]',
  isAdult: true // Error - TS2322: Type 'true' is not assignable to type 'string | number'.
};
console.log(user['name']); // 'Neo'
console.log(user['age']); // 123
console.log(user['email']); // [email protected]

keyof

인덱싱 가능 타입에서 keyof를 사용하면 속성 이름을 타입으로 사용할 수 있습니다.
인덱싱 가능 타입의 속성 이름들이 유니온 타입으로 적용됩니다.
간단한 예제를 살펴보겠습니다.

interface ICountries {
  KR: '대한민국',
  US: '미국',
  CP: '중국'
}
let country: keyof ICountries; // 'KR' | 'US' | 'CP'
country = 'KR'; // ok
country = 'RU'; // Error - TS2322: Type '"RU"' is not assignable to type '"KR" | "US" | "CP"'.

또한 keyof를 통한 인덱싱으로 타입의 개별 값에도 접근할 수 있습니다.

interface ICountries {
  KR: '대한민국',
  US: '미국',
  CP: '중국'
}
let country: ICountries[keyof ICountries]; // ICountries['KR' | 'US' | 'CP']
country = '대한민국';
country = '러시아'; // Error - TS2322: Type '"러시아"' is not assignable to type '"대한민국" | "미국" | "중국"'.

인터페이스 확장

인터페이스도 클래스처럼 extends 키워드를 활용해 상속할 수 있습니다.

interface IAnimal {
  name: string
}
interface ICat extends IAnimal {
  meow(): string
}

class Cat implements ICat { // Error - TS2420: Class 'Cat' incorrectly implements interface 'ICat'. Property 'name' is missing in type 'Cat' but required in type 'ICat'.
  meow() {
    return 'MEOW~'
  }
}

그리고 같은 이름의 인터페이스를 여러 개 만들 수도 있습니다.
기존에 만들어진 인터페이스에 내용을 추가하는 경우에 유용합니다.

interface IFullName {
  firstName: string,
  lastName: string
}
interface IFullName {
  middleName: string
}

const fullName: IFullName = {
  firstName: 'Tomas',
  middleName: 'Sean',
  lastName: 'Connery'
};

타입 별칭(Type Aliases)

type 키워드를 사용해 새로운 타입 조합을 만들 수 있습니다.
하나 이상의 타입을 조합해 별칭(이름)을 부여하며, 정확히는 조합한 각 타입들을 참조하는 별칭을 만드는 것입니다.
일반적인 경우 둘 이상의 조합으로 구성하기 위해 유니온을 많이 사용합니다.

TUser에서 T는 Type를 의미하는 별칭으로 사용했습니다.

type MyType = string;
type YourType = string | number | boolean;
type TUser = {
  name: string,
  age: number,
  isValid: boolean
} | [string, number, boolean];

let userA: TUser = {
  name: 'Neo',
  age: 85,
  isValid: true
};
let userB: TUser = ['Evan', 36, false];

function someFunc(arg: MyType): YourType {
  switch (arg) {
    case 's':
      return arg.toString(); // string
    case 'n':
      return parseInt(arg); // number
    default:
      return true; // boolean
  }
}

제네릭(Generic)

Generic은 재사용을 목적으로 함수나 클래스의 선언 시점이 아닌, 사용 시점에 타입을 선언할 수 있는 방법을 제공합니다.

타입을 인수로 받아서 사용한다고 이해하면 쉽습니다.

다음 예제는 toArray 함수가 인수로 받은 값을 배열로 반환하도록 작성되었습니다.
매개 변수가 Number 타입만 허용하기 때문에 String 타입을 인수로 하는 함수 호출에서 에러가 발생합니다.

function toArray(a: number, b: number): number[] {
  return [a, b];
}
toArray(1, 2);
toArray('1', '2'); // Error - TS2345: Argument of type '"1"' is not assignable to parameter of type 'number'.

조금 더 범용적으로 만들기 위해 유니언 방식을 사용했습니다.
이제 String 타입을 인수로 받을 수 있지만, 가독성이 떨어지고 새로운 문제도 발생했습니다.
세 번째 호출을 보면 의도치 않게 Number와 String 타입을 동시에 받을 수 있게 되었습니다.

function toArray(a: number | string, b: number | string): (number | string)[] {
  return [a, b];
}
toArray(1, 2); // Only Number
toArray('1', '2'); // Only String
toArray(1, '2'); // Number & String

이번에는 Generic을 사용합니다.
함수 이름 우측에 <T>를 작성해 시작합니다.
T는 타입 변수(Type variable)로 사용자가 제공한 타입으로 변환될 식별자입니다.
이제 세 번째 호출은 의도적으로 Number와 String 타입을 동시에 받을 수 있습니다.(혹은 유니언을 사용하지 않으면 에러가 발생합니다)

타입 변수는 매개 변수처럼 원하는 이름으로 지정할 수 있습니다.

function toArray<T>(a: T, b: T): T[] {
  return [a, b];
}

toArray<number>(1, 2);
toArray<string>('1', '2');
toArray<string | number>(1, '2');
toArray<number>(1, '2'); // Error

타입 추론을 활용해, 사용 시점에 타입을 제공하지 않을 수 있습니다.

function toArray<T>(a: T, b: T): T[] {
  return [a, b];
}

toArray(1, 2);
toArray('1', '2');
toArray(1, '2'); // Error

제약 조건(Constraints)

인터페이스나 타입 별칭을 사용하는 제네릭을 작성할 수도 있습니다.
다음 예제는 별도의 제약 조건(Constraints)이 없어서 모든 타입이 허용됩니다.

interface MyType<T> {
  name: string,
  value: T
}

const dataA: MyType<string> = {
  name: 'Data A',
  value: 'Hello world'
};
const dataB: MyType<number> = {
  name: 'Data B',
  value: 1234
};
const dataC: MyType<boolean> = {
  name: 'Data C',
  value: true
};
const dataD: MyType<number[]> = {
  name: 'Data D',
  value: [1, 2, 3, 4]
};

만약 타입 변수 Tstringnumber인 경우만 허용하려면 아래 예제와 같이 extends 키워드를 사용하는 제약 조건을 추가할 수 있습니다.
기본 문법은 다음과 같습니다.

T extends U
interface MyType<T extends string | number> {
  name: string,
  value: T
}

const dataA: MyType<string> = {
  name: 'Data A',
  value: 'Hello world'
};
const dataB: MyType<number> = {
  name: 'Data B',
  value: 1234
};
const dataC: MyType<boolean> = { // TS2344: Type 'boolean' does not satisfy the constraint 'string | number'.
  name: 'Data C',
  value: true
};
const dataD: MyType<number[]> = { // TS2344: Type 'number[]' does not satisfy the constraint 'string | number'.
  name: 'Data D',
  value: [1, 2, 3, 4]
};

대표적으로 typeinterface 키워드를 사용하는 타입 선언은 다음 예제와 같이 = 기호를 기준으로 ‘식별자’와 ‘타입 구현’으로 구분할 수 있습니다.
제약 조건은 ‘식별자’ 영역에서 사용하는 extends에 한합니다.

type U = string | number | boolean;

// type 식별자 = 타입 구현
type MyType<T extends U> = string | T;

// interface 식별자 { 타입 구현 }
interface IUser<T extends U> {
  name: string,
  age: T
}

조건부 타입(Conditional Types)

제약 조건과 다르게 ‘타입 구현’ 영역에서 사용하는 extends는 삼항 연산자(Conditional ternary operator)를 사용할 수 있습니다.
이를 조건부 타입(Conditional Types)이라고 하며 다음과 같은 문법을 가집니다.

T extends U ? X : Y
type U = string | number | boolean;

// type 식별자 = 타입 구현
type MyType<T> = T extends U ? string : never;

// interface 식별자 { 타입 구현 }
interface IUser<T> {
  name: string,
  age: T extends U ? number : never
}
// `T`는 `boolean` 타입으로 제한.
interface IUser<T extends boolean> {
  name: string,
  age: T extends true ? string : number, // `T`의 타입이 `true`인 경우 `string` 반환, 아닌 경우 `number` 반환.
  isString: T
}

const str: IUser<true> = {
  name: 'Neo',
  age: '12', // String
  isString: true
}
const num: IUser<false> = {
  name: 'Lewis',
  age: 12, // Number
  isString: false
}

다음과 같이 삼항 연산자를 연속해서 사용할 수도 있습니다.

type MyType<T> =
  T extends string ? 'Str' :
  T extends number ? 'Num' :
  T extends boolean ? 'Boo' :
  T extends undefined ? 'Und' :
  T extends null ? 'Nul' :
  'Obj';

infer

infer 키워드를 사용해 타입 변수의 타입 추론(Inference) 여부를 확인할 수 있습니다.
기본 문법은 다음과 같습니다.

U가 추론 가능한 타입이면 참, 아니면 거짓

T extends infer U ? X : Y

유용하진 않지만, 이해를 위한 아주 간단한 예제를 살펴봅시다.
기본 구조는 위에서 살펴본 조건부 타입과 같습니다.

type MyType<T> = T extends infer R ? R : null;

const a: MyType<number> = 123;

여기서 타입 변수 RMyType<number>에서 받은 타입 number가 되고 infer 키워드를 통해 타입 추론이 가능한지 확인합니다.
number 타입은 당연히 타입 추론이 가능하니 R을 반환하게 됩니다.(만약 R을 타입 추론할 수 없다면 null이 반환됩니다)
결과적으로 MyType<number>number를 반환하고 변수 a123을 할당할 수 있습니다.

이번에는 조금 더 복잡하지만 유용한 예제를 하나 살펴봅시다.
ReturnType는 함수의 반환 값이 어떤 타입인지 반환합니다.

‘TS 유틸리티 타입 > ReturnType’ 파트를 참고하세요.

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

function fn(num: number) {
  return num.toString();
}

const a: ReturnType<typeof fn> = 'Hello';

위 예제에서 typeof fn(num: number) => string으로 반환 타입은 string입니다.
따라서 Rstring이고 역시 infer 키워드를 통해서 타입 추론이 가능하기 때문에 R을 반환합니다.
즉, string을 반환합니다.

infer 키워드에 대한 더 자세한 내용은 공식 문서의 Type inference in conditional types 파트를 참고하세요.
문서 내용은 다음과 같이 간단히 정리했습니다.

  • infer 키워드는 제약 조건 extends가 아닌 조건부 타입 extends 절에서만 사용 가능
  • infer 키워드는 같은 타입 변수를 여러 위치에서 사용 가능
    • 일반적인 공변성(co-variant) 위치에선 유니언 타입으로 추론
    • 함수 인수인 반공변성(contra-variant) 위치에선 인터섹션 타입으로 추론
  • 여러 호출 시그니처(함수 오버로드)의 경우 마지막 시그니처에서 추론

공변성과 반공변성에 대한 자세한 내용은 다음 포스트를 참고하세요.
TypeScript에서의 공변성과 반공변성 (strictFunctionTypes)

함수

기본적인 함수 사용에 대해선 위에서 살펴봤습니다.
여기서는 타입스크립트 함수의 주요 특징들에 대해서 살펴봅시다.

this

함수를 다루는 데 있어 가장 중요한 내용 중 하나가 바로 this입니다.
함수 내 this는 전역 객체를 참조하거나(sloppy mode), undefined(strict mode)가 되는 등 우리가 원하는 콘텍스트(context)를 잃고 다른 값이 되는 경우들이 있습니다.

const obj = {
  a: 'Hello~',
  b: function () {
    console.log(this.a); // obj.a
    // Inner function
    function b() {
      console.log(this.a); // global.a
    }
  }
};

특히 ‘호출하지 않는 메소드’를 사용하는 경우에 this로 인한 문제가 발생합니다.
우선, 다음 예제를 살펴봅시다.
객체 데이터 obj에서 b 메소드는 a 속성을 this를 통해 참조하고 있습니다.

const obj = {
  a: 'Hello~',
  b: function () {
    console.log(this.a);
  }
};

위 객체를 기준으로 아래 예제와 같이 ‘호출하지 않는 메소드’를 사용(할당)하는 경우, this가 유효한 콘텍스트를 잃어버리고 a를 참조할 수 없게 됩니다.

많은 경우 콜백 함수가 해당합니다.

obj.b(); // Hello~

const b = obj.b;
b(); // Cannot read property 'a' of undefined

function someFn(cb: any) {
  cb();
}
someFn(obj.b); // Cannot read property 'a' of undefined

setTimeout(obj.b, 100); // undefined

이런 상황에서 this 콘텍스트가 정상적으로 유지되어 a 속성을 참조할 수 있는 방법을 알아봅시다.

첫 번째는 bind 메소드를 사용해 this직접 연결해 주는 방법입니다.

타입스크립트에서 bind, call, apply 메소드는 기본적으로 인수 타입 체크를 하지 않기 때문에, 컴파일러 옵션에서 strict: true(혹은 strictBindCallApply: true)를 지정해 줘야 정상적으로 타입 체크를 하게 됩니다.

obj.b(); // Hello~

const b = obj.b.bind(obj);
b(); // Hello~

function someFn(cb: any) {
  cb();
}
someFn(obj.b.bind(obj)); // Hello~

setTimeout(obj.b.bind(obj), 100); // Hello~

두 번째는 화살표 함수를 사용하는 방법입니다.
다음과 같이 화살표 함수를 이용해 유효한 콘텍스트를 유지하면서 메소드를 호출합니다.

화살표 함수는 호출된 곳이 아닌 함수가 생성된 곳에서 this를 캡처합니다.

obj.b(); // Hello~

const b = () => obj.b();
b(); // Hello~

function someFn(cb: any) {
  cb();
}
someFn(() => obj.b()); // Hello~

setTimeout(() => obj.b(), 100); // Hello~

만약 클래스의 메소드 멤버를 정의하는 경우, 프로토타입(prototype) 메소드가 아닌 화살표 함수를 사용할 수 있습니다.

class Cat {
  constructor(private name: string) {}
  getName = () => {
    console.log(this.name);
  }
}
const cat = new Cat('Lucy');
cat.getName(); // Lucy

const getName = cat.getName;
getName(); // Lucy

function someFn(cb: any) {
  cb();
}
someFn(cat.getName); // Lucy

여기서 주의할 점은 인스턴스를 생성할 때마다 개별적인 getName이 만들어지게 되는데, 일반적인 메소드 호출에서의 화살표 함수 사용은 비효율적이지만 만약에 메소드를 주로 콜백으로 사용하는 경우엔 프로토타입의 새로운 클로져 호출보다 화살표 함수의 생성된 getName 참조가 훨씬 효율적일 수 있습니다.

prototype method member & arrow function

각 방법은 메모리와 성능에 대한 트레이드-오프(trade-off)입니다.
상황에 맞게 선택하는 것이 좋습니다.

명시적 this

다음 예제를 살펴보면, someFn 함수 내 this가 캡처할 수 있는 cat 객체를 call 메소드를 통해 전달 및 실행했지만, 엄격 모드에서 this는 암시적인(implicitly) any 타입이기 때문에 에러가 발생합니다.

‘엄격 모드’는 컴파일러 옵션에서 strict: true(혹은 noImplicitThis: true)인 경우를 말합니다.

interface ICat {
  name: string
}

const cat: ICat = {
  name: 'Lucy'
};

function someFn(greeting: string) {
  console.log(`${greeting} ${this.name}`); // Error - TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
}
someFn.call(cat, 'Hello'); // Hello Lucy

이 경우 this의 타입을 명시적으로(explicitly) 선언할 수 있습니다.
다음과 같이 첫 번째 가짜(fake) 매개변수로 this를 선언합니다.

interface ICat {
  name: string
}

const cat: ICat = {
  name: 'Lucy'
};

function someFn(this: ICat, greeting: string) {
  console.log(`${greeting} ${this.name}`); // ok
}
someFn.call(cat, 'Hello'); // Hello Lucy

오버로드(Overloads)

타입스크립트의 ‘함수 오버로드(Overloads)’는 이름은 같지만 매개변수 타입과 반환 타입이 다른 여러 함수를 가질 수 있는 것을 말합니다.
함수 오버로드를 통해 다양한 구조의 함수를 생성하고 관리할 수 있습니다.

아래 예제에서 add 함수는 2개의 선언부와 1개의 구현부를 가지고 있습니다.
주의할 점은 함수 선언부와 구현부의 매개변수 개수가 같아야 합니다.

함수 구현부에 any가 자주 사용됩니다.

function add(a: string, b: string): string; // 함수 선언
function add(a: number, b: number): number; // 함수 선언
function add(a: any, b: any): any { // 함수 구현
  return a + b;
}

add('hello ', 'world~');
add(1, 2);
add('hello ', 2); // Error - No overload matches this call.

인터페이스나 타입 별칭 등의 메소드 정의에서도 오버로드를 활용할 수 있습니다.
타입 단언이나 타입 가드를 통해 함수 선언부의 동적인 매개변수와 반환 값을 정의할 수 있습니다.

interface IUser {
  name: string,
  age: number,
  getData(x: string): string[];
  getData(x: number): string;
}

let user: IUser = {
  name: 'Neo',
  age: 36,
  getData: (data: any) => {
    if (typeof data === 'string') {
      return data.split('');
    } else {
      return data.toString();
    }
  }
};

user.getData('Hello'); // ['h', 'e', 'l', 'l', 'o']
user.getData(123); // '123'

HTMLDivElement 같이 DOM 타입의 선언부를 살펴보면 다음과 같습니다.

/** Provides special properties (beyond the regular HTMLElement interface it also has available to it by inheritance) for manipulating <div> elements. */
interface HTMLDivElement extends HTMLElement {
    /**
     * Sets or retrieves how the object is aligned with adjacent text.
     */
    /** @deprecated */
    align: string;
    addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

WebStorm go to declaration or usages

Declaration or Usages in WebStorm

VSCode go to definition

Go to Definition in VSCode

클래스

클래스의 생성자 메소드(constructor)와 일반 메소드(Methods) 멤버(Class member)와는 다르게, 속성(Properties)은 name: string;와 같이 클래스 바디(Class body)에 별도로 타입을 선언합니다.

클래스 바디(Class body)는 중괄호 {}로 묶여 있는 영역을 의미합니다.

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class Cat extends Animal {
  getName(): string {
    return `Cat name is ${this.name}.`;
  }
}
let cat: Cat;
cat = new Cat('Lucy');
console.log(cat.getName()); // Cat name is Lucy.

클래스 수식어(Modifiers)

타입스크립트와 관련된 클래스 수식어들을 살펴봅시다.

클래스 멤버(속성, 메소드)에서 사용할 수 있는 접근 제어자(Access Modifiers)들이 있습니다.
각 접근 제어자들의 차이점을 이해해 봅시다.

접근 제어자(Access Modifiers)는 클래스, 메서드 및 기타 멤버의 접근 가능성을 설정하는 객체 지향 언어의 키워드입니다.

접근 제어자의미범위
public어디서나 자유롭게 접근 가능(생략 가능)속성, 메소드
protected나와 파생된 후손 클래스 내에서 접근 가능속성, 메소드
private내 클래스에서만 접근 가능속성, 메소드

다음 수식어들은 위 접근 제어자와 함께 사용할 수 있습니다.
static의 경우 타입스크립트에서는 정적 메소드뿐만 아니라 정적 속성도 사용할 수 있습니다.

수식어의미범위
static정적으로 사용속성, 일반 메소드
readonly읽기 전용으로 사용속성

그럼 우선, 각 접근 제어자들의 차이점에 대해서 살펴봅시다.

다음 예제의 Animal 클래스의 name 속성은 public이기 때문에 파생된 자식 클래스(Cat)에서 this.name으로 참조하거나 인스턴스에서 cat.name으로 접근하는데 아무런 문제가 없습니다.
(어디서나 자유롭게 접근 가능(생략 가능))

class Animal {
  // public 수식어 사용(생략 가능)
  public name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class Cat extends Animal {
  getName(): string {
    return `Cat name is ${this.name}.`;
  }
}
let cat = new Cat('Lucy');
console.log(cat.getName()); // Cat name is Lucy.

cat.name = 'Tiger';
console.log(cat.getName()); // Cat name is Tiger.

다음 예제의 Animal 클래스의 name 속성은 protected이기 때문에 파생된 자식 클래스(Cat)에서 this.name으로 참조할 수는 있지만, 인스턴스에서 cat.name으로는 접근할 수 없습니다.
(나와 파생된 후손 클래스 내에서 접근 가능)

class Animal {
  // protected 수식어 사용
  protected name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class Cat extends Animal {
  getName(): string {
    return `Cat name is ${this.name}.`;
  }
}
let cat = new Cat('Lucy');
console.log(cat.getName()); // Cat name is Lucy.
console.log(cat.name); // Error - TS2445: Property 'name' is protected and only accessible within class 'Animal' and its subclasses.

cat.name = 'Tiger'; // Error - TS2445: Property 'name' is protected and only accessible within class 'Animal' and its subclasses.
console.log(cat.getName());

다음 예제의 Animal 클래스의 name 속성은 private이기 때문에 파생된 자식 클래스(Cat)에서 this.name으로 참조할 수 없고, 인스턴스에서도 cat.name으로 접근할 수도 없습니다.
(내 클래스에서만 접근 가능)

class Animal {
  // private 수식어 사용
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class Cat extends Animal {
  getName(): string {
    return `Cat name is ${this.name}.`; // Error - TS2341: Property 'name' is private and only accessible within class 'Animal'
  }
}
let cat = new Cat('Lucy');
console.log(cat.getName());
console.log(cat.name); // Error - TS2341: Property 'name' is private and only accessible within class 'Animal'.

cat.name = 'Tiger'; // Error - TS2341: Property 'name' is private and only accessible within class 'Animal'.
console.log(cat.getName());

다음은 생성자 메소드(constructor)에 protected를 사용했기 때문에 인스턴스 생성에서 에러가 발생하는 예제입니다.

class Animal {
  name: string;
  protected constructor(name: string) {
    this.name = name;
  }
}
const cat = new Animal('Dog'); // Error - TS2674: Constructor of class 'Animal' is protected and only accessible within the class declaration.

그리고 흥미로운 부분은 생성자 메소드에서 인수 타입 선언과 동시에 접근 제어자를 사용하면 바로 속성 멤버로 정의할 수 있습니다.
접근 제어자를 생략하지 않도록 주의하세요.

class Cat {
  constructor(public name: string, protected age: number) {}
  getName() {
    return this.name;
  }
  getAge() {
    return this.age;
  }
}

const cat = new Cat('Neo', 2);
console.log(cat.getName()); // Neo
console.log(cat.getAge()); // 2

이번엔 staticreadonly에 대해서 살펴봅시다.

ES6에서는 static으로 정적 메소드만 생성할 수 있었는데, 타입스크립트에서는 정적 속성도 생성할 수 있습니다.
정적 속성은 클래스 바디에서 속성의 타입 선언과 같이 사용하며, 정적 메소드와 다르게 클래스 바디에서 값을 초기화할 수 없기 때문에 constructor 혹은 메소드에서 초기화가 필요합니다.

class Cat {
  static legs: number;
  constructor() {
    Cat.legs = 4; // Init static property.
  }
}
console.log(Cat.legs); // undefined
new Cat();
console.log(Cat.legs); // 4

class Dog {
  // Init static method.
  static getLegs() {
    return 4;
  }
}
console.log(Dog.getLegs()); // 4

readonly을 사용하면 해당 속성은 ‘읽기 전용’입니다.

class Animal {
  readonly name: string;
  constructor(n: string) {
    this.name = n;
  }
}
let dog = new Animal('Charlie');
console.log(dog.name); // Charlie
dog.name = 'Tiger'; // Error - TS2540: Cannot assign to 'name' because it is a read-only property.

그리고 staticreadonly는 접근 제어자와 같이 사용할 수 있습니다.
접근 제어자를 먼저 작성해야 합니다.

class Cat {
  public readonly name: string;
  protected static eyes: number;
  constructor(n: string) {
    this.name = n;
    Cat.eyes = 2;
  }
  private static getLegs() {
    return 4;
  }
}

추상(Abstract) 클래스

추상(Abstract) 클래스는 다른 클래스가 파생될 수 있는 기본 클래스로, 인터페이스와 굉장히 유사합니다.
abstract는 클래스뿐만 아니라 속성과 메소드에도 사용할 수 있습니다.
추상 클래스는 직접 인스턴스를 생성할 수 없기 때문에 파생된 후손 클래스에서 인스턴스를 생성해야 합니다.

// Abstract Class
abstract class Animal {
  abstract name: string; // 파생된 클래스에서 구현해야 합니다.
  abstract getName(): string; // 파생된 클래스에서 구현해야 합니다.
}
class Cat extends Animal {
  constructor(public name: string) {
    super();
  }
  getName() {
    return this.name;
  }
}
new Animal(); // Error - TS2511: Cannot create an instance of an abstract class.
const cat = new Cat('Lucy');
console.log(cat.getName()); // Lucy

// Interface
interface IAnimal {
  name: string;
  getName(): string;
}
class Dog implements IAnimal {
  constructor(public name: string) {}
  getName() {
    return this.name;
  }
}

추상 클래스가 인터페이스와 다른 점은 속성이나 메소드 멤버에 대한 세부 구현이 가능하다는 점입니다.

abstract class Animal {
  abstract name: string;
  abstract getName(): string;
  // Abstract class constructor can be made protected.
  protected constructor(public legs: string) {}
  getLegs() {
    return this.legs
  }
}

Optional

? 키워드를 사용하는 여러 선택적(Optional) 개념에 대해서 살펴봅시다.

매개 변수(Parameters)

우선, 타입을 선언할 때 선택적 매개 변수(Optional Parameter)를 지정할 수 있습니다.
다음 예제를 보면 ? 키워드를 사용해 y를 선택적 매개 변수로 지정했습니다.
따라서 y가 받을 인수가 없어도 에러가 발생하지 않습니다.

function add(x: number, y?: number): number {
  return x + (y || 0);
}
const sum = add(2);
console.log(sum);

위 예제는 정확히 다음 예제와 같습니다.
즉, ? 키워드 사용은 | undefined를 추가하는 것과 같습니다.

function add(x: number, y: number | undefined): number {
  return x + (y || 0);
}
const sum = add(2, undefined);
console.log(sum);

속성과 매소드(Properties and Methods)

? 키워드를 속성(Properties)과 메소드(Methods) 타입 선언에도 사용할 수 있습니다.
다음은 인터페이스 파트에서 살펴봤던 예제입니다.
isAdult를 선택적 속성으로 선언하면서 더 이상 에러가 발생하지 않습니다.

interface IUser {
  name: string,
  age: number,
  isAdult?: boolean
}

let user1: IUser = {
  name: 'Neo',
  age: 123,
  isAdult: true
};

let user2: IUser = {
  name: 'Evan',
  age: 456
};

Type이나 Class에서도 사용할 수 있습니다.

interface IUser {
  name: string,
  age: number,
  isAdult?: boolean,
  validate?(): boolean
}
type TUser = {
  name: string,
  age: number,
  isAdult?: boolean,
  validate?(): boolean
}
abstract class CUser {
  abstract name: string;
  abstract age: number;
  abstract isAdult?: boolean;
  abstract validate?(): boolean;
}

체이닝(Chaining)

다음 예제는 str 속성이 undefined일 경우 toString 메소드를 사용할 수 없기 때문에 에러가 발생합니다.
str 속성이 문자열이라는 것을 단언하면 문제를 해결할 수 있지만, 더 간단하게 선택적 체이닝(Optional Chaining) 연산자 ?.를 사용할 수 있습니다.

자세한 사용법은 MDN 문서를 참고하세요.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/Optional_chaining

obj?.prop;
obj?.[expr];
arr?.[index];
func?.(args);
// Error - TS2532: Object is possibly 'undefined'.
function toString(str: string | undefined) {
  return str.toString();
}

// Type Assertion
function toString(str: string | undefined) {
  return (str as string).toString();
}

// Optional Chaining
function toString(str: string | undefined) {
  return str?.toString();
}

특히 && 연산자를 사용해 각 속성을 Nullish 체크(null이나 undefined를 확인)하는 부분에서 유용합니다.

// Before
if (foo && foo.bar && foo.bar.baz) {}

// After-ish
if (foo?.bar?.baz) {}

Nullish 병합 연산자

일반적으로 논리 연산자 ||를 사용해 Falsy 체크(0, "", NaN, null, undefined를 확인)하는 경우가 많습니다.
여기서 0이나 "" 값을 유효 값으로 사용하는 경우 원치 않는 결과가 발생할 수 있는데, 이럴 때 유용한 Nullish 병합(Nullish Coalescing) 연산자 ??를 타입스크립트에서 사용할 수 있습니다.

자세한 사용법은 MDN 문서를 참고하세요.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator

const foo = null ?? 'Hello nullish.';
console.log(foo); // Hello nullish.

const bar = false ?? true;
console.log(bar); // false

const baz = 0 ?? 12;
console.log(baz); // 0

모듈

타입스크립트의 모듈을 이해하기 위해선 자바스크립트 모듈에 대한 이해가 선행되어야 합니다.
타입스크립트 공식 문서의 많은 부분이 이 자바스크립트 모듈에 대한 설명을 포함하고 있는데, 여기서는 타입스크립트가 가지는 모듈 개념의 차이점에 대해서만 살펴보려고 합니다.

내보내기(export)와 가져오기(import)

자바스크립트 모듈의 내보내기(export)와 가져오기(import)에 대해 이해가 부족하다면 다음 MDN 문서를 우선 참고하시길 바랍니다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/export
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/import

타입스크립트는 일반적인 변수나 함수, 클래스뿐만 아니라 다음과 같이 인터페이스나 타입 별칭도 모듈로 내보낼 수 있습니다.

// myTypes.ts

// 인터페이스 내보내기
export interface IUser {
  name: string,
  age: number
}

// 타입 별칭 내보내기
export type MyType = string | number;
// 선언한 모듈(myTypes.ts) 가져오기
import { IUser, MyType } from './myTypes';

const user: IUser = {
  name: 'HEROPY',
  age: 85
};

const something: MyType = true; // Error - TS2322: Type 'true' is not assignable to type 'MyType'.

타입스크립트는 CommonJS/AMD/UMD 모듈을 위해 export = ABC;, import ABC = require('abc');와 같은 내보내기와 가져오기 문법을 제공합니다.
이는 ES6 모듈의 export default 같이 하나의 모듈에서 하나의 객체만 내보내는 Default Export 기능을 제공합니다.

결국 타입스크립트에서 CommonJS/AMD/UMD 모듈은 다음과 같이 가져올 수 있습니다.
추가로, 컴파일 옵션에 "esModuleInterop": true를 제공하면, ES6 모듈의 Default Import 방식도 같이 사용할 수 있습니다.

// CommonJS/AMD/UMD
import ABC = require('abc');
// or
import * as ABC from 'abc';
// or `"esModuleInterop": true`
import ABC from 'abc';

모듈의 타입 선언(Ambient module declaration)

타입스크립트의 외부 자바스크립트 모듈 사용에 대해서 알아봅시다.
간단한 프로젝트를 생성하고 외부 모듈로 Lodash를 설치해 사용해 보겠습니다.

‘모듈’ 파트의 타입스크립트 프로젝트 생성은 ‘개발환경 / TS Node’ 파트를 참고하세요.

$ npm install lodash

main.ts에서 Lodash 모듈의 camelCase API를 사용해 콘솔 출력하는 아주 단순한 코드를 작성합니다.
하지만 다음과 같이 ‘가져오기(import)’ 단계에서 에러가 발생합니다.
이는 타입스크립트 컴파일러가 확인할 수 있는 모듈의 타입 선언(Ambient module declaration)이 없기 때문입니다.

// main.ts

import * as _ from 'lodash'; // Error - TS2307: Cannot find module 'lodash'.

console.log(_.camelCase('import lodash module'));

모듈 구현(implement)과 타입 선언(declaration)이 동시에 이뤄지는 타입스크립트와 달리, 구현만 존재하는 자바스크립트 모듈(E.g. Lodash)을 사용하는 경우, 컴파일러가 이해할 수 있는 모듈의 타입 선언이 필요하며, 이를 대부분 .d.ts파일로 만들어 제공하게 됩니다.

그럼 이제 Lodash에 대한 타입 선언을 해봅시다.
다음과 같이 루트 경로에 lodash.d.ts 파일을 생성합니다.

Ambient modules

기본 구조는 단순합니다.
모듈 가져오기(Import)가 가능하도록 module 키워드를 사용해 모듈 이름을 명시합니다.
그리고 그 범위 안에서, 타입(interface)을 가진 변수(_)를 선언하고 내보내기(Export)만 하면 됩니다.

타입스크립트 컴파일러가 이해할 수 있도록 declare 키워드를 통해 선언해야 합니다!

// lodash.d.ts

// 모듈의 타입 선언(Ambient module declaration)
declare module 'lodash' {
  // 1. 타입(인터페이스) 선언
  interface ILodash {
    camelCase(str?: string): string
  }

  // 2. 타입(인터페이스)을 가지는 변수 선언
  const _: ILodash;

  // 3. 내보내기(CommonJS)
  export = _;
}

그리고 이 타입 선언이 컴파일 과정에 포함될 수 있도록 다음과 같이 ///(삼중 슬래시 지시자, Triple-slash directive)를 사용하는 참조 태그(<reference />)path 속성을 사용합니다.
넘어가기 전, 참조 태그의 특징에 대해서 몇 가지 살펴보면,

  • 참조 태그로 가져오는 것은 모듈 구현이 아니라 타입 선언이기 때문에, import 키워드로 가져오지 않아야 합니다.
  • 삼중 슬래시 지시자는 자바스크립트로 컴파일되면 단순 주석입니다.
  • path 속성은 가져올 타입 선언의 상대 경로를 지정하며, 확장자를 꼭 입력해야 합니다.
  • types 속성은 /// <reference types="lodash" />와 같이 모듈 이름을 지정하며, 이는 컴파일 옵션 typeRoots 와 Definitely Typed(@types)를 기준으로 합니다.

컴파일 옵션 typeRoots와 Definitely Typed(@types)는 뒤에서 살펴봅니다.

// 참조 태그(Triple-slash directive)
/// <reference path="./lodash.d.ts" />

import * as _ from 'lodash';

console.log(_.camelCase('import lodash module'));

정상적으로 콘솔 출력되는지 확인합니다.

$ npx ts-node main.ts
# importLodashModule

Definitely Typed(@types)

이전 파트에서 Lodash의 camelCase 메소드를 사용했고, 이번엔 추가로 snakeCase도 사용하려고 합니다.
하지만 우리는 lodash.d.tssnakeCase에 대한 타입 선언을 하지 않았기 때문에 다음과 같이 에러가 발생합니다.

// main.ts

/// <reference path="./lodash.d.ts" />

import * as _ from 'lodash';

console.log(_.camelCase('import lodash module'));
console.log(_.snakeCase('import lodash module')); // Error - TS2339: Property 'snakeCase' does not exist on type 'ILodash'.

이는 lodash.d.tssnakeCase에 대한 타입 선언을 하면 간단히 해결할 수 있습니다.

// lodash.d.ts

declare module 'lodash' {
  interface ILodash {
    camelCase(str?: string): string,
    snakeCase(str?: string): string // 타입 선언 추가
  }

  const _: ILodash;
  export = _;
}

하지만, 프로젝트에서 사용하는 모든 모듈에 대해 매번 직접 타입 선언을 작성하는 것(타이핑, Typing)은 매우 비효율적입니다.
그래서 우리는 여러 사용자들의 기여로 만들어진 Definitely Typed을 사용할 수 있습니다.
수 많은 모듈의 타입이 정의되어 있으며, 지속적으로 추가되고 있습니다.

npm install -D @types/모듈이름으로 설치해 사용합니다.
npm info @types/모듈이름으로 검색하면 원하는 모듈의 타입 선언이 존재하는지 확인할 수 있습니다.

다음과 같이 Lodash 타입 선언을 설치합니다.

$ npm i -D @types/lodash

이제, 더 이상 필요치 않으니 lodash.d.ts를 삭제합니다!
main.ts의 참조 태그(Triple-slash directive)도 같이 삭제합니다!
별도 설정이 없어도, 다양한 Lodash API를 사용할 수 있습니다.

// main.ts

import * as _ from 'lodash';

console.log(_.camelCase('import lodash module'));
console.log(_.snakeCase('import lodash module'));
console.log(_.kebabCase('import lodash module'));
$ npx ts-node main.ts
# importLodashModule
# import_lodash_module
# import-lodash-module

동작 원리는 간단합니다.
타입 선언 모듈(@types/lodash)은 node_modules/@types경로에 설치되며,
이 경로의 모든 타입 선언은 모듈 가져오기(Import)를 통해 컴파일에 자동으로 포함됩니다.

camelCase type declaration in @types/lodash

typeRoots와 types 옵션

위에서 살펴본 것과 같이, 자바스크립트 모듈을 사용할 때 다음과 같이 타입 선언을 고민하지 않아도 되는 상황들이 있습니다.

  • 처음부터 타입스크립트로 작성된 모듈
  • 타입 선언(.d.ts 파일 등)을 같이 제공하는 자바스크립트 모듈
  • Definitely Typed(@types/모듈)에 타입 선언이 기여된 자바스크립트 모듈

하지만 어쩔 수 없이 직접 타입 선언을 작성(타이핑, Typing)해야 하는 다음과 같은 상황들도 고려해야 합니다.

  • 타입 선언을 찾을 수 없는 자바스크립트 모듈
  • 가지고 있는 타입 선언을 수정해야 하는 경우

위에서 작성했던 lodash.d.ts와 같이 직접 타입 선언을 작성해서 제공할 수 있으며, 이를 좀 더 쉽게 관리할 방법으로 컴파일 옵션 typeRoots를 사용할 수 있습니다.

typeRoots 옵션을 테스트하기 위해, 새로운 프로젝트를 만들어 아래와 같이 Lodash를 설치하고 main.ts 파일을 생성합니다.

‘모듈’ 파트의 타입스크립트 프로젝트 생성은 ‘개발환경 / TS Node’ 파트를 참고하세요.

$ npm install lodash

역시 ‘가져오기(Import)’ 단계에서 에러가 발생하네요.

// main.ts

import * as _ from 'lodash'; // Error - TS2307: Cannot find module 'lodash'.

console.log(_.camelCase('import lodash module'));

이를 해결하기 위해,
아래와 같이 index.d.ts 파일을 types/lodash 경로에 생성하고,
tsconfig.json 파일 컴파일 옵션으로 "typeRoots": ["./types"]를 제공합니다.
넘어가기 전, typeRoots 옵션의 특징에 대해서 몇 가지 살펴보면,

  • 기본값은 "typeRoots": ["./node_modules/@types"]입니다.
  • typeRoots 옵션은 지정된 경로에서 index.d.ts 파일을 우선 탐색합니다.
  • index.d.ts 파일이 없다면 package.jsontypes 혹은 typings 속성에 작성된 경로와 파일 이름을 탐색합니다.
  • 타입 선언을 찾을 수 없으면 컴파일 오류가 발생합니다.
// types/lodash/index.d.ts

declare module 'lodash' {
  interface ILodash {
    camelCase(str?: string): string
  }
  const _: ILodash;
  export = _;
}

types directory
typeRoots compile option

이제 정상적으로 동작합니다.

$ npx ts-node main.ts
# importLodashModule

이렇게 typeRoots 옵션을 통해 types 디렉터리에서 여러 모듈의 타입 선언을 관리할 수 있으며,
디렉터리 이름은 types 뿐만 아니라 @types, _types, typings 등 자유롭게 사용할 수 있습니다.

추가로 컴파일러 옵션 types를 통해 화이트리스트(Whitelist) 방식으로 사용할 모듈 이름만을 작성할 수 있는데,
"types": ["lodash"]로 작성하면 types 디렉터리에서 Lodash의 타입 선언만을 사용하며,
"types": []로 작성하면 types 디렉터리의 모든 모듈의 타입 선언을 사용하지 않음을 의미하며,
types 옵션을 사용하지 않으면 types 디렉터리의 모든 모듈의 타입 선언을 사용하게 됩니다.

모듈 이름을 추가하고 삭제하면서 어떻게 동작하는지 확인하면 이해하는 데 도움이 될 것입니다.

일반적인 경우 types 옵션을 작성할 필요가 없습니다.

types compile option

TS 유틸리티 타입

타입스크립트에서 제공하는 여러 전역 유틸리티 타입이 있습니다.
이해를 돕기 위한 간단한 예제를 포함했습니다.
더 자세한 내용은 Utility Types를 참고하세요.

타입 변수 T는 타입(Type), U는 또 다른 타입, K는 속성(key)을 의미하는 약어입니다.
이해를 돕기 위해 타입 변수를 TTYPE 또는 TYPE1, UTYPE2, KKEY로 명시했습니다.

유틸리티 이름설명 (대표 타입)타입 변수
PartialTYPE의 모든 속성을 선택적으로 변경한 새로운 타입 반환 (인터페이스)<TYPE>
RequiredTYPE의 모든 속성을 필수로 변경한 새로운 타입 반환 (인터페이스)<TYPE>
ReadonlyTYPE의 모든 속성을 읽기 전용으로 변경한 새로운 타입 반환 (인터페이스)<TYPE>
RecordKEY를 속성으로, TYPE를 그 속성값의 타입으로 지정하는 새로운 타입 반환 (인터페이스)<KEY, TYPE>
PickTYPE에서 KEY로 속성을 선택한 새로운 타입 반환 (인터페이스)<TYPE, KEY>
OmitTYPE에서 KEY로 속성을 생략하고 나머지를 선택한 새로운 타입 반환 (인터페이스)<TYPE, KEY>
ExcludeTYPE1에서 TYPE2를 제외한 새로운 타입 반환 (유니언)<TYPE1, TYPE2>
ExtractTYPE1에서 TYPE2를 추출한 새로운 타입 반환 (유니언)<TYPE1, TYPE2>
NonNullableTYPE에서 nullundefined를 제외한 새로운 타입 반환 (유니언)<TYPE>
ParametersTYPE의 매개변수 타입을 새로운 튜플 타입으로 반환 (함수, 튜플)<TYPE>
ConstructorParametersTYPE의 매개변수 타입을 새로운 튜플 타입으로 반환 (클래스, 튜플)<TYPE>
ReturnTypeTYPE의 반환 타입을 새로운 타입으로 반환 (함수)<TYPE>
InstanceTypeTYPE의 인스턴스 타입을 반환 (클래스)<TYPE>
ThisParameterTypeTYPE의 명시적 this 매개변수 타입을 새로운 타입으로 반환 (함수)<TYPE>
OmitThisParameterTYPE의 명시적 this 매개변수를 제거한 새로운 타입을 반환 (함수)<TYPE>
ThisTypeTYPEthis 컨텍스트(Context)를 명시, 별도 반환 없음! (인터페이스)<TYPE>

Partial

TYPE의 모든 속성을 선택적(?)으로 변경한 새로운 타입을 반환합니다.

‘Optional > 속성과 메소드’ 파트를 참고하세요.

Partial<TYPE>
interface IUser {
  name: string,
  age: number
}

const userA: IUser = { // TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'IUser'.
  name: 'A'
};
const userB: Partial<IUser> = {
  name: 'B'
};

위 예제의 Partial<IUser>은 다음과 같이 이해할 수 있습니다.

interface INewType {
  name?: string,
  age?: number
}

Required

TYPE의 모든 속성을 필수로 변경한 새로운 타입을 반환합니다.

Required<TYPE>
interface IUser {
  name?: string,
  age?: number
}

const userA: IUser = {
  name: 'A'
};
const userB: Required<IUser> = { // TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'Required<IUser>'.
  name: 'B'
};

위 예제의 Required<IUser>은 다음과 같이 이해할 수 있습니다.

interface IUser {
  name: string,
  age: number
}

Readonly

TYPE의 모든 속성을 읽기 전용(readonly)으로 변경한 새로운 타입을 반환합니다.

‘인터페이스 > 읽기 전용 속성’ 파트를 참고하세요.

Readonly<TYPE>
interface IUser {
  name: string,
  age: number
}

const userA: IUser = {
  name: 'A',
  age: 12
};
userA.name = 'AA';

const userB: Readonly<IUser> = {
  name: 'B',
  age: 13
};
userB.name = 'BB'; // TS2540: Cannot assign to 'name' because it is a read-only property.

위 예제의 Readonly<IUser>는 다음과 같이 이해할 수 있습니다.

interface INewType {
  readonly name: string,
  readonly age: number
}

Record

KEY를 속성(Key)으로, TYPE를 그 속성값의 타입(Type)으로 지정하는 새로운 타입을 반환합니다.

Record<KEY, TYPE>
type TName = 'neo' | 'lewis';

const developers: Record<TName, number> = {
  neo: 12,
  lewis: 13
};

위 예제의 Record<TName, number>는 다음과 같이 이해할 수 있습니다.

interface INewType {
  neo: number,
  lewis: number
}

Pick

TYPE에서 KEY로 속성을 선택한 새로운 타입을 반환합니다.
TYPE은 속성을 가지는 인터페이스나 객체 타입이어야 합니다.

Pick<TYPE, KEY>
interface IUser {
  name: string,
  age: number,
  email: string,
  isValid: boolean
}
type TKey = 'name' | 'email';

const user: Pick<IUser, TKey> = {
  name: 'Neo',
  email: '[email protected]',
  age: 22 // TS2322: Type '{ name: string; email: string; age: number; }' is not assignable to type 'Pick<IUser, TKey>'.
};

위 예제의 Pick<IUser, TKey>은 다음과 같이 이해할 수 있습니다.

interface INewType {
  name: string,
  email: string
}

Omit

위에서 살펴본 Pick과 반대로,
TYPE에서 KEY로 속성을 생략하고 나머지를 선택한 새로운 타입을 반환합니다.
TYPE은 속성을 가지는 인터페이스나 객체 타입이어야 합니다.

Omit<TYPE, KEY>
interface IUser {
  name: string,
  age: number,
  email: string,
  isValid: boolean
}
type TKey = 'name' | 'email';

const user: Omit<IUser, TKey> = {
  age: 22,
  isValid: true,
  name: 'Neo' // TS2322: Type '{ age: number; isValid: true; name: string; }' is not assignable to type 'Pick<IUser, "age" | "isValid">'.
};

위 예제의 Omit<IUser, TKey>은 다음과 같이 이해할 수 있습니다.

interface INewType {
  // name: string,
  age: number,
  // email: string,
  isValid: boolean
}

Exclude

유니언 TYPE1에서 유니언 TYPE2를 제외한 새로운 타입을 반환합니다.

Exclude<TYPE1, TYPE2>
type T = string | number;

const a: Exclude<T, number> = 'Only string';
const b: Exclude<T, number> = 1234; // TS2322: Type '123' is not assignable to type 'string'.
const c: T = 'String';
const d: T = 1234;

Extract

유니언 TYPE1에서 유니언 TYPE2를 추출한 새로운 타입을 반환합니다.

Extract<TYPE1, TYPE2>
type T = string | number;
type U = number | boolean;

const a: Extract<T, U> = 123;
const b: Extract<T, U> = 'Only number'; // TS2322: Type '"Only number"' is not assignable to type 'number'.

NonNullable

유니언 TYPE에서 nullundefined를 제외한 새로운 타입을 반환합니다.

NonNullable<TYPE>
type T = string | number | undefined;

const a: T = undefined;
const b: NonNullable<T> = null; // TS2322: Type 'null' is not assignable to type 'string | number'.

Parameters

함수 TYPE의 매개변수 타입을 새로운 튜플(Tuple) 타입으로 반환합니다.

Parameters<TYPE>
function fn(a: string | number, b: boolean) {
  return `[${a}, ${b}]`;
}

const a: Parameters<typeof fn> = ['Hello', 123]; // Type 'number' is not assignable to type 'boolean'.

위 예제의 Parameters<typeof fn>은 다음과 같이 이해할 수 있습니다.

[string | number, boolean]

ConstructorParameters

클래스 TYPE의 매개변수 타입을 새로운 튜플 타입으로 반환합니다.

ConstructorParameters<TYPE>
class User {
  constructor (public name: string, private age: number) {}
}

const neo = new User('Neo', 12);
const a: ConstructorParameters<typeof User> = ['Neo', 12];
const b: ConstructorParameters<typeof User> = ['Lewis']; // TS2741: Property '1' is missing in type '[string]' but required in type '[string, number]'.

위 예제의 ConstructorParameters<typeof User>은 다음과 같이 이해할 수 있습니다.

[string, number]

ReturnType

함수 TYPE의 반환(Return) 타입을 새로운 타입으로 반환합니다.

ReturnType<TYPE>
function fn(str: string) {
  return str;
}

const a: ReturnType<typeof fn> = 'Only string';
const b: ReturnType<typeof fn> = 1234; // TS2322: Type '123' is not assignable to type 'string'.

InstanceType

클래스 TYPE의 인스턴스 타입을 반환합니다.

InstanceType<TYPE>
class User {
  constructor(public name: string) {}
}

const neo: InstanceType<typeof User> = new User('Neo');

ThisParameterType

함수 TYPE의 명시적 this 매개변수 타입을 새로운 타입으로 반환합니다.
함수 TYPE에 명시적 this 매개변수가 없는 경우 알 수 없는 타입(Unknown)을 반환합니다.

‘함수 > this > 명시적 this’ 파트를 참고하세요.

ThisParameterType<TYPE>
// https://www.typescriptlang.org/docs/handbook/utility-types.html#thisparametertype

function toHex(this: Number) {
    return this.toString(16);
}

function numberToString(n: ThisParameterType<typeof toHex>) {
    return toHex.apply(n);
}

위 예제에서 함수 toHex의 명시적 this 타입은 Number이고,
그 타입을 참고해서 함수 numberToString의 매개변수 n의 타입을 선언합니다.
따라서 toHex에 다른 타입의 this가 바인딩 되는 것을 방지할 수 있습니다.

OmitThisParameter

함수 TYPE의 명시적 this 매개변수를 제거한 새로운 타입을 반환합니다.

OmitThisParameter<TYPE>
function getAge(this: typeof cat) {
  return this.age;
}

// 기존 데이터
const cat = {
  age: 12 // Number
};
getAge.call(cat); // 12

// 새로운 데이터
const dog = {
  age: '13' // String
};
getAge.call(dog); // TS2345: Argument of type '{ age: string; }' is not assignable to parameter of type '{ age: number; }'.

위 예제에서 데이터 cat을 기준으로 설계한 함수 getAge는 일부 다른 타입을 가지는 새로운 데이터 dogthis로 사용할 수 없습니다.
하지만 OmitThisParameter를 통해 명시적 this를 제거한 새로운 타입의 함수를 만들 수 있기 때문에,
getAge를 직접 수정하지 않고 데이터 dog를 사용할 수 있습니다.

const getAgeForDog: OmitThisParameter<typeof getAge> = getAge;
getAgeForDog.call(dog); // '13'

this.age에는 이제 어떤 값도 들어갈 수 있음을 주의합니다.

ThisType

TYPEthis 컨텍스트(Context)를 명시하고 별도의 타입을 반환하지 않습니다.

ThisType<TYPE>
interface IUser {
  name: string,
  getName: () => string
}

function makeNeo(methods: ThisType<IUser>) {
  return { name: 'Neo', ...methods } as IUser;
}
const neo = makeNeo({
  getName() {
    return this.name;
  }
});

neo.getName(); // Neo

함수 makeNeo의 인수로 사용되는 메소드 getName은 내부에서 this.name을 사용하고 있기 때문에 ThisType을 통해 명시적으로 this 컨텍스트를 설정해 줍니다.
단, ThisType은 별도의 타입을 반환하지 않기 때문에 makeNeo 반환 값({ name: 'Neo', ...methods })에 대한 타입이 정상적으로 추론(Inference)되지 않습니다.
따라서 as IUser와 같이 따로 타입을 단언(Assertions)해야 neo.getName을 정상적으로 호출할 수 있습니다.

참고 자료(References)

https://www.typescriptlang.org/docs/home.html
https://www.tutorialsteacher.com/typescript
https://hyunseob.github.io/2017/12/12/typescript-type-inteference-and-type-assertion/
https://jsdev.kr/t/typescript-interface/3168
https://github.com/microsoft/TypeScript/wiki/‘this’-in-TypeScript
https://github.com/Microsoft/TypeScript-Handbook/issues/180
https://medium.com/naver-fe-platform/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC%EA%B0%80-%EB%AA%A8%EB%93%88-%ED%83%80%EC%9E%85-%EC%84%A0%EC%96%B8%EC%9D%84-%EC%B0%B8%EC%A1%B0%ED%95%98%EB%8A%94-%EA%B3%BC%EC%A0%95-5bfc55a88bb6

공지 이미지
{{ title }}
{{ eventPlace }}
{{ eventDate }} ({{ eventDay }}) {{ eventTime }}
오늘 하루 그만 보기