TypeScript 개념 및 설치

곽태욱·2020년 9월 2일
1

TypeScript 개념

TypeScript는 JavaScript에 정적 타입 기능을 추가한 언어로서 JavaScript를 모두 포함한다. 즉, 항상 TypeScript를 JavaScript로 변환(트랜스파일)할 수 있다는 뜻이다. TypeScript는 각 변수마다 정적 타입 설정을 강제하진 않기 때문에 컴파일 오류(엄밀히는 트랜스파일 오류)가 발생해도 JavaScript로 변환할 수 있다. (근데 권장하진 않는다.. 그럴 바엔 JavaScript 쓰자)

컴파일 오류는 TypeScript에서 JavaScript로 변환할 때 생기는 오류고, 런타임 오류는 변환된 JavaScript를 실행하면서 발생하는 오류다.

TypeScript의 간단한 테스트는 아래의 공식 사이트에서 할 수 있다.
https://www.typescriptlang.org/play/

Type 종류

// Primitive type
const num: number = 3;
const big: bigint = 11111111111111n;
const str: string = 's';
const bool: boolean = true;
const und: undefined = undefined;
const nul: null = null;
const obj: object = {};
// symbol?
const a: any = 3;  // 모든 자료형 할당 가능

// Array
const numArr: number[] = [1, 2, 3];
const strArr: string[] = ['a', 'b', 'c'];
const mixArr: (number | string)[] = [1, 'a', 2, 'b', 3];

// Tuple
const tup1: [number, number] = [1, 2];
const tup2: [number, string] = [1, 'a'];
const tup3: [string, number] = ['b', 2];
const tup4: [boolean, number] = [false, 2];
const tup5: [string, boolean, number] = ['c', false, 2];

// Normal function
function add(n: number, n2: number): number {
  return n + n2;
}

// Arrow function
const sub: (arg1: number, arg2: number) => number = (n, n2) => {
  return n - n2;
}

// unknown?

// Function returning never must have unreachable end point
function infiniteLoop(): never {
  while (true) {
  }
}
function e(message: string): never {
  throw new Error(message);
}

// Function returning void must not return anything
function v(): void {
  console.log('return type: void');
}

TypeScript가 지원하는 자료형은 위와 같다.

TypeScript 활용

// Type 선언 (interface도 가능)
type Dimensions3 = {
  x: number;  		// 무조건 있어야 되는 항목
  y: number;		
  z: number;
  comment?: string;	// ?는 해당 항목이 있어도 되고 없어도 된다는 뜻 (Optional)
};

// Type alias (Intersection types)
type Dimensions4 = Dimensions3 & {
  w: number;  // Dimension3 모든 항목에 추가로 w 항목이 존재
};

// 변수의 구조와 자료형을 명시할 수 있다.
const point1: Dimensions3 = {
  x: 1,
  y: 2,
  z: 3,
};

// 변수의 구조와 자료형을 명시할 수 있다.
const point2: Dimensions4 = {
  x: 1,
  y: 2,
  z: 3,
  w: 4,
};

// 함수의 매개변수와 반환값의 자료형을 명시할 수 있다.
function add(point1: Dimensions3, point2: Dimensions3): Dimensions3 {
  return {
    x: point1.x + point2.x,
    y: point1.y + point2.y,
    z: point1.z + point2.z,
  }
}

add(point1, point1)  // 가능
add(point1, point2)  // 가능 (Dimensions4가 Dimensions3를 포함하기 때문)
add(1, 2)  // 컴파일, 런타임 오류 (number는 Dimensions3가 아니기 때문)

C, C++, Java 등 정적 타입 언어의 자료형과 비슷한 개념이다. 컴파일 시점에 자료형에 의한 오류를 쉽게 찾을 수 있다는 장점이 있다.

Type vs Interface

문법의 차이일 뿐 기능 상 거의 동일하다.

타입은 type alias로 타입을 확장하고, 인터페이스는 extends로 타입을 확장한다.

  • C언어의 구조체와 비슷한 개념

  • C++의 클래스와 비슷한 개념

  • Java의 인터페이스, 클래스 비슷한 개념

Type Alias

type Age = number;
const age: Age = 82;

기존에 존재하는 type이나 사용자 지정 type에 새로운 이름을 부여할 수 있다. 근데 생각해보면 당연한 기능이다. 변수에 이름을 설정하는 것과 마찬가지로 type도 이름이 있어야 해당 type을 불러올 수 있기 때문이다.

Intersection Types (&)

type A = {
  a: string;
  b: number;
};

type B = {
  c: boolean;
  d: number[];  // number[]는 원소가 number인 배열이라는 뜻
};

// Intersection type
type C = A & B;

/* type C는 아래와 동일
type C = {
  a: string;
  b: number;
  c: boolean;
  d: number[];
}; */

// 가능
const c1: C = {
  a: 'aaa',
  b: 1,
  c: true,
  d: [1, 2, 3],
};

// 컴파일 오류
const c2: C = {
  a: 'aaa',
  b: 1,  // c와 d가 빠져있기 때문
};

기존 type에 새로운 항목을 추가해 간편하게 새로운 이름의 type을 만들 수 있다.

Union Types (|)

type A = {
  a: string;
  b: number;
};

type B = {
  c: boolean;
  d: number[];
};

// Union type
type C = A | B;
type D = 1 | 'd';

// 가능
const c1: C = {
  a: 'aaa',
  b: 1,
};

// 가능
const c2: C = {
  c: true,
  d: [1, 2, 3],
};

// 가능
const c3: C = {
  a: 'aaa',
  b: 1,
  c: true,
  d: [1, 2, 3],
};

const d1: D = 1;    // 가능
const d2: D = 'd';  // 가능
const d3: D = 3;    // 위 2가지 경우 외 모두 컴파일 오류

TypeScript만의 특이한 개념으로서 여러 개의 type을 또는으로 묶을 수 있다. 이 개념은 매개변수 자료형이 여러 개인 경우와 함수 오버로딩, MECE 형태의 자료형을 표현할 때 사용된다.

Generics

type A<T> = {
  a: T;
  b: string;
};

/* A<number>는 아래와 동일
type A = {
  a: number;
  b: string;
} */

/* A<boolean>은 아래와 동일
type A = {
  a: boolean;
  b: string;
} */

// 가능
const a1: A<number> = {
  a: 1,
  b: 'bbb',
}

// 가능
const a2: A<boolean> = {
  a: true,
  b: 'bbb',
}

// 컴파일 오류
const a3: A<string> = {
  a: true,  // a는 문자열이어야 함
  b: 'bbb',
}

각 type의 세부 자료형을 상황에 따라 유연하게 결정할 수 있어서 type의 재사용성이 좋아진다. 우리가 공통되는 코드를 함수로 만들어서 재사용하는 것과 비슷한 맥락이다.

Type Inference

const num = 1;     // number
const str = 'bb';  // string
const arr = [1, 2, 3];  // number[]
const arr2 = [true, 2, false, 4];  // (number | boolean)[]

num = 'aa';  // 컴파일 오류 (변수 num엔 number만 할당 가능)

TypeScript는 JavaScript를 포함하기 때문에 다른 동적 타입 언어(Python 등)와 마찬가지로 변수의 자료형을 명시하지 않으면 TypeScript 컴파일러가 자료형을 알아서 추측해준다. 그 후 변수에 다른 자료형의 값을 대입하면 컴파일 오류가 발생한다.

Type Assertion

type Bird = {
  layEggs: () => void;  // 매개변수와 반환값이 없는 형태의 함수
  fly: () => void;
}

type Fish = {
  layEggs: () => void;
  swim: () => void;
}

// 숫자를 받아 Bird 또는 Fish 또는 null을 반환하는 함수
function getPet(i: number): Bird | Fish | null {
  switch (i) {
    // Bird 객체 반환
    case 1:
      return {
        layEggs: () => {console.log("Bird layEggs")},
        fly: () => {console.log("fly")},
      };

    // Fish 객체 반환
    case 2:
      return {
        layEggs: () => {console.log("Fish layEggs")},
        swim: () => {console.log("swim")},
      };
  }

  // null 반환
  return null;
}

const pet = getPet(1);  // Bird 객체 반환
pet.layEggs();  // 컴파일 오류 (getPet 함수의 반환값이 null일 수 있기 때문)
pet!.layEggs(); // 가능. !는 null이 아니라고 가정하는 것 (Not-Nullable)
pet!.fly();          // 컴파일 오류
(pet as Bird).fly(); // 가능 (Type assertion)

const pet2 = getPet(2); // Fish 객체 반환
pet2!.layEggs();        // 가능
pet2!.swim();           // 컴파일 오류
(pet as Fish).swim();   // 가능 (Type assertion)

const pet3 = getPet(3); // null 반환
pet3!.layEggs();        // 런타임 오류
pet3!.fly();            // 컴파일, 런타임 오류

어떤 값의 자료형이 여러 가지일 때, 런타임 시 해당 값의 자료형이 무엇일지 추측하는 것은 컴파일러 입장에선 많이 어렵다. 그래서 Bird 또는 Fish의 공통된 함수인 layEggs()를 호출할 땐 컴파일 오류가 없지만, 객체 자료형에 따라 없을 수도 있는 함수인 fly()swim()을 호출할 땐 컴파일 오류가 발생한다. 이와 같은 이유로 프로그래머가 직접 '런타임 때 해당 변수의 자료형은 무엇이다'라고 지정해주는 type assertion이 필요하다. Type assertion은 as 키워드를 사용하는데 변수명 as 타입명으로 해당 변수의 자료형을 설정할 수 있다.

Type assertion은 컴파일 시 자료형을 TypeScript에게 단순히 알려주는 것으로, 런타임 시 실제로 값이 다른 자료형으로 변하는 type casting과는 약간 다른 개념이다. 즉, type assertion은 데이터는 그대로 두고 그 데이터를 해석하는 방식을 바꾸는 개념이다. 그래서 Type assertion은 C언어의 포인터 형 변환이랑 비슷하고, type casting은 객체 내용을 문자열로 변환하는 Java의 Object.toString()과 비슷하다. 아래가 JavaScript에서의 type casting이다.

const a = 1;
const b = 'a';
const c = a + b;  // Type casting (a값이 정수에서 문자열로 변환됨)
console.log(c);   // '1a'

Type Guard

interface A {
  id: number;
  isA: boolean;
  str: string;
}
interface B {
  id: number;
  isB: boolean;
  num: number;
}

function f(a: A | B) {
  console.log(a.id);   // 가능
  console.log(a.str);  // 컴파일 오류. 매개변수 a가 B 객체면 런타임 오류도 발생
  console.log(a.num);  // 컴파일 오류. 매개변수 a가 A 객체면 런타임 오류도 발생
}

Type guard는 type assertion과 비슷한 개념으로서 if문으로 변수의 자료형을 특정할 수 있으면 해당 중괄호 범위 내에서 변수의 자료형을 한정해준다. 기본적으로 위와 같은 상황에서 컴파일러는 함수 f의 매개변수 a의 자료형을 특정할 수 없기 때문에 type assertion에서와 비슷한 문제가 발생한다.

function f(a: A | B) {
  if ("str" in a) {
    console.log(a.id);   // 가능
    console.log(a.str);  // 가능
    console.log(a.isA);  // 가능
  } else {
    console.log(a.id);   // 가능
    console.log(a.num);  // 가능
    console.log(a.isB);  // 가능
  }
}

그래서 위와 같이 in 키워드를 사용해서 매개변수 a가 어떤 인터페이스인지 확인할 수 있다. "str"은 인터페이스 A와 B의 공통되지 않은 항목의 이름을 적으면 된다. 여기선 "isA", "str", "isB", "num" 중 하나를 사용해야 type guard가 되고 "id"를 사용하면 안 된다.

class classA implements A {
  id = 0;
  str = "a";
  isA = true;
}

class classB implements B {
  id = 1;
  num = 2;
  isB = true;
}

function f(a: A | B): void {
  if (a instanceof classA) {
    console.log(a.id);   // 가능
    console.log(a.str);  // 가능
    console.log(a.isA);  // 가능
  } else if (a instanceof classB) {
    console.log(a.id);   // 가능
    console.log(a.num);  // 가능
    console.log(a.isB);  // 가능
  }
}

또는 instanceof를 사용해 해당 클래스의 객체인지 확인할 수 있다.

function f(a: number | string): void {
  if (typeof a === "number") {
    ...
  } else if (typeof a === "string") {
    ...
  }
}

또는 typeof를 사용해서 해당 변수의 기본 자료형을 확인할 수 있다.

function isA(foo: A | B): foo is A {
  return (user as A).str !== undefined
}

function f(a: Admin | User) {
  if (isA(a)) {
    console.log(a.id);   // 가능
    console.log(a.str);  // 가능
    console.log(a.isA);  // 가능
  } else {
    console.log(a.id);   // 가능
    console.log(a.num);  // 가능
    console.log(a.isB);  // 가능
  }
}

또는 사용자 정의 type guard 함수를 사용해서 클래스나 인터페이스를 확인할 수 있다. 여기서 중요한 점은 함수 반환형에 foo is A를 명시하는 것이다. 그래야 TypeScript가 매개변수 foo를 interface A라고 특정할 수 있다.

Type guard는 type assertion과 다르게 컴파일돼도 JavaScript의 동적 타입 검사를 수행한다는 장점이 있다.

  • Python의 type()함수랑 비슷하지만, Python에는 객체의 자료형을 한정하는 개념은 없다.

Declaration Merging

// 이렇게
interface Person {
  name: string;
}

// 동일한 이름을 가진
interface Person {
  age: number;
}

// 여러 개의 interface를 정의할 수 있다.
interface Person {
  height: number;
  isHungry: boolean;
}

/* 위 3개 interface는 아래와 같이 하나로 합쳐진 상태로 인식된다.
interface Person {
  name: string;
  age: number;
  height: number;
  isHungry: boolean;
} */

이렇게 동일한 인터페이스를 여러 번 선언하면 나중에 안 헷갈리나

Declaration merging은 여러 개의 자료형을 하나로 합친다는 점에서 interface extends와 intersection types랑 비슷한 맥락이다. Declaration merging은 동일한 이름의 인터페이스를 자동으로 하나로 합치는 거고, interface extends는 기존의 여러 인터페이스를 합쳐서 새로운 이름의 인터페이스를 생성하는 거고, intersection types는 기존의 여러 type을 합쳐서 새로운 이름의 type을 생성하는 거다.

TypeScript 설치

본 글은 JavaScript 패키지 관리자로 yarn을 사용하고 있습니다. npm을 사용한다면 명령어를 적절히 바꿔서 사용해주세요.

mkdir project-name
cd project-name

위 명령어를 통해 프로젝트 폴더를 생성한다.

yarn init -y
yarn add typescript --dev

그리고 위 명령어를 통해 package.json을 생성하고 TypeScript를 개발 종속성(devDependancy)으로 추가한다. TypeScript는 런타임에 필요하지 않기 때문이다. 또는 TypeScript를 모든 프로젝트에서 사용할 수 있도록 global 키워드를 사용해 전역으로 설치해도 된다.

yarn tsc --init

위 명령어를 통해 TypeScript 컴파일러 옵션 파일인 tsconfig.json 파일을 생성할 수 있다. 해당 파일엔 여러 가지 컴파일러 옵션이 주석으로 처리되어 있는데 TypeScript의 사용 의의를 달성하기 위해 웬만하면 type check 옵션은 전부 켜두자.

다양한 컴파일러 옵션은 아래 공식 사이트에서 확인할 수 있다.
https://www.typescriptlang.org/docs/handbook/compiler-options.html

// index.ts
const a: number = 3;

그리고 프로젝트 폴더 내에 TypeScript 파일을 생성한다. 파일 이름과 내용은 적절히 설정해준다.

yarn tsc

그리고 위 명령어를 통해 최신? TypeScript 컴파일러를 해당 프로젝트에 설치하지 않고도 실행할 수 있다. TypeScript 컴파일러가 실행되면 프로젝트에 있는 모든 폴더 내 모든 .ts 파일이 각각 .js 파일로 변환될 것이다.

Prettier 적용

Prettier는 코드를 자동으로 정렬하는 라이브러리로서 개발 편의성을 높일 수 있다. 코드 이쁘게 하느라 시간 낭비하지 않아도 된다는 말이다. Prettier는 로컬에 따로 설치하고 명령창에 명령어를 입력해서 사용하거나, 편집기(vscode)의 확장 프로그램을 이용해서 사용할 수 있다. 보통은 vscode의 Prettier 확장 프로그램을 사용하는 것이 편하다. 아래는 정석적인 Prettier 적용법을 설명한다.

yarn add prettier --dev --exact

위 명령어로 로컬 저장소에 Prettier를 설치할 수 있다. 로컬에 Prettier를 따로 설치하는 이유는 Prettier 버전을 고정함으로서 버전이 달라지면서 생기는 불필요한 파일 수정을 피하기 위함이다. 그게 상관없다면 로컬에 따로 설치하지 않고 vscode의 확장 프로그램을 사용하면 된다.

// .prettierrc.json
{
  "printWidth": 120,
  // "tabWidth": 2,
  // "useTabs": false,
  // "semi": true,
  "singleQuote": true,
  // "quoteProps": "as-needed",
  // "jsxSingleQuote": false,
  // "trailingComma": "es5",
  // "bracketSpacing": true,
  // "jsxBracketSameLine": false,
  // "arrowParens": true
}

위와 같이 .prettierrc.json 파일을 생성한 후 적절한 파일 포맷 옵션을 설정한다. 옵션을 설정하지 않으면 Prettier 기본 설정값으로 파일을 포맷한다.

다양한 Prettier 옵션은 아래의 공식 사이트에서 확인할 수 있다.
https://prettier.io/docs/en/options.html

yarn prettier --write .

위 명령어를 입력하면 현재 디렉토리 내 모든 파일을 포맷해준다. 특정 폴더나 파일만 정렬하도록 . 대신 해당 경로를 명시할 수 있다. 그리고 --write 대신 --check 옵션을 사용하면 변경이 필요한 파일만 변경해줘 불필요한 덮어쓰기를 방지할 수 있다. .prettierignore 파일로 포맷에서 제외할 파일을 따로 관리할 수 있다.

여기까지가 Prettier를 적용하는 정석적인 방법인데 포맷할 때마다 매번 명령어를 입력하는 것이 귀찮을 수 있다. 따라서 아래와 같이 vscode의 Prettier 확장 프로그램을 설치하면 파일을 저장할 때마다 자동으로 파일 포맷이 이뤄지도록 설정할 수 있다. 아래의 경우 Prettier를 로컬에 따로 설치하지 않으면 최신 버전 포맷 방식을 따른다.

  1. vscode에서 Prettier 확장 프로그램을 설치하고
  2. vscode 설정에서 formatOnSave 옵션을 키면
  3. 파일을 저장할 때마다 해당 파일을 자동으로 포맷해준다.

따라서 프로젝트 협업으로 팀원끼리 코드 포맷을 통일해야 하거나 Prettier 버전을 고정할 필요가 있을 땐 로컬에 Prettier를 설치해서 vscode의 Prettier 확장 프로그램과 같이 사용하면 좋다.

ESLint 적용

TypeScript with React

yarn create react-app project-name --template typescript

위 명령어로 TypeScript가 적용된 React 템플릿을 생성할 수 있다.

import React, { useState } from "react";

function App() {
  const [counter, setCounter] = useState<number>(0);

  const onClick = (e) => setCounter(counter + 1);

  return (
    <>
      <div>{counter}</div>
      <button onClick={onClick}>+1</button>
    </>
  );
}

export default App;

위와 같이 TypeScript를 통해 useState()의 자료형을 제네릭으로 설정할 수 있다. 근데 사실 useState<number>(0);에선 제네릭이 없어도 TypeScript가 number라고 알아서 추측해준다. 하지만 useState()에서 초기값이 null, {}, []일 땐 자료형을 추측할 수 없기 때문에 제네릭이 필요하다.



// Type 선언
type Dimensions3 = {
  x: number;
  y: number;		
  z: number;
  comment?: string;
}

// React defaultProps
Dimensions3.defaultProps = {
  z: 0  // z 항목이 없으면 z 항목을 생성하고 값을 0으로 초기화
};


useRef<>()

리액트에선 type을 추천한다.
리액트에선 Type assertion으로 as를 사용해야 한다.

참고한 사이트

profile
이유와 방법을 알려주는 메모장 겸 블로그. 블로그 내용에 대한 토의나 질문은 언제나 환영합니다.

0개의 댓글