타입스크립트 기초

Minho Yoo·2022년 11월 30일
1

TypeScript

목록 보기
2/3
post-thumbnail

타입스크립트 기본 타입

타입 스크립트로 변수나 함수와 같은 자바스크립트 코드에 타입을 정의할 수 있다.
타입스크립트의 기본 타입에는 크게 12가지가 있다.

  • Boolean
  • Number
  • String
  • Object
  • Array
  • Tuple
  • Enum
  • Any
  • Void
  • Null
  • Undefined
  • Never

String

자바스크립트 변수의 타입이 문자열인 경우 아래와 같이 선언해서 사용한다.

let str: string = 'hi';

TIP

위와 같이 :를 이용하여 자바스크립트 코드에 타입을 정의하는 방식을 타입 표기(Type Annotation)라고 한다.

Number

타입이 숫자이면 아래와 같이 선언한다.

let num: number = 10;

Boolean

타입이 진위 값인 경우에는 아래와 같이 선언한다.

let isLoggedIn: boolean = false;

Object

Array

타입이 배열인 경우 간단하게 아래와 같이 선언한다.

let arr: number[] = [1, 2, 3];

또는 아래와 같이 제네릭을 사용할 수 있다.

let arr: Array<number> = [1, 2, 3];

Tuple

튜플은 배열의 길이가 고정되고 각 요소의 타입이 지정되어 있는 배열 형식을 의미한다.

let arr: [string, number] = ['hi', 10];

만약 정의하지 않은 타입, 인덱스로 접근할 경우 오류가 발생한다.

arr[1].concat('!'); // Error, 'number' does not hav 'concat'
arr[5] = 'hello'; // Error, Property '5' does not exist on type '[string, number]'.

Enum

이넘은 C, Java와 같은 다른 언어에서 흔하게 쓰이는 타입으로 특정 값(상수)들의 집합을 의미한다.

enum Avengers { Capt, IronMan, Thor }
let hero: Avengers = Avengers.Capt;

이넘은 인덱스 번호로도 접근할 수 있다.

enum Avengers { Capt, IronMan, Thor }
let hero: Avengers = Avengers[0];

만약 원한다면 이넘의 인덱스를 사용자 편의로 변경하여 사용할 수도 있다.

enum Avengers { Capt = 2, IronMan, Thor }
let hero: Avengers = Avengers[2]; // Capt
let hero: Avengers = Avengers[4]; // Thor

Any

기존에 자바스크립트로 구현되어 있는 웹 서비스 코드에 타입스크립트를 점진적으로 적용할 때 활용하면 좋은 타입이다.
단어 의미 그대로 모든 타입에 대해서 허용한다는 의미를 갖고 있다.

let str: any = 'hi';
let num: any = 10;
let arr: any = ['a', 2, true];

Void

변수에는 undefinednull만 할당하고, 함수에는 반환 값을 설정할 수 없는 타입이다.

let unuseful: void = undefined;
function notuse(): void {
  console.log('sth');
}

Never

함수의 끝에 절대 도달하지 않는다는 의미를 지닌 타입이다.

// 이 함수는 절대 함수의 끝까지 실행되지 않는다는 의미
function neverEnd(): never {
  while (true) {
  
  }
}

타입스크립트에서의 함수

웹 애플리케이션을 구현할 때 자주 사용되는 함수는 타입스크립트로 크게 다음 3가지 타입을 정의할 수 있다.

  • 함수의 파라미터(매개변수) 타입
  • 함수의 반환 타입
  • 함수의 구조 타입

함수의 기본적인 타입 선언

타입스크립트의 함수 선언 방법을 이해하기 위해 먼저 간단한 자바스크립트 함수를 보겠다.

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

위 자바스크립트 함수에 타입을 부여하면 아래와 같다.

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

기존 자바스크립트 함수의 선언 방식에서 매개변수와 함수의 반환 값에 타입을 추가하였다.

TIP

함수의 반환 값에 타입을 정하지 않을 때는 void라도 사용

함수의 인자

타입스크립트에서는 함수의 인자를 모두 필수 값으로 간주한다.
따라서, 함수의 매개변수를 설정하면 undefinednull이라도 인자로 넘겨야하며 컴파일러에서 정의된 매개변수 값이 넘어 왔는지 확인한다.
달리 말하면 정의된 매개변수 값만 받을 수 있고 추가로 인자를 받을 수 없다는 의미이다.

function sum(a: number, b: number): number {
  return a + b;
}
sum(10, 20); // 30
sum(10, 20, 30); // error, too many parameters
sum(10); // Error, two few parameters

위와 같은 특성은 정의된 매개변수의 갯수 만큼 인자를 넘기지 않아도 되는 자바스크립트의 특성과 반대된다.
만약 이러한 특성을 살리고 싶다면 ?를 이용해서 아래와 같이 정의할 수 있다.

function sum(a: number, b?: number): number {
  return a + b;
}
sum(10, 20); // 30
sum(10, 20, 30); // error, too many parameters
sum(10); // 타입 에러 없음

매개변수 초기화는 ES6 문법과 동일하다.

function sum(a: number, b = 100): number {
  return a + b;
}
sum(10, undefined); // 110
sum(10, 20, 30); // error, too many parameters
sum(10); // 110

REST 문법이 적용된 매개변수

ES6 문법에서 지원하는 Rest 문법은 타입스크립트에서 다음과 같이 사용할 수 있다.

function sum(a: number, ...nums: number[]): number {
  const totalOfNums = 0;
  for (let key in nums) {
    totalOfNums += nums[key];
  }
  return a + totalOfNums;
}

this

타입스크립트에서 자바스크립트의 this가 잘못 사용되었을 때 감지할 수 있다.
타입스크립트에서 this가 가리키는 것을 명시하려면 아래와 같은 문법을 사용한다.

function 함수명(this: 타입) {
  // ...
}

그럼 위 문법을 실제 예제에 적용해보겠다.

interface Vue {
  el: string,
  count: number,
  init(this: Vue): () => {};
}
  
let vm: Vue = {
  el: '#app',
  count: 10,
  init: function(this: Vue) {
    return () => {
      return this.count;
    }
  }
}

let getCount = vm.init();
let count = getCount();
console.log(count); // 10

위의 코드를 타입스크립트로 컴파일 했을 때 만일 --noImplicitThis 옵션이 있더라도 에러가 발생하지 않는다.

콜백에서의 this

앞에서 살펴본 일반적인 상황에서의 this와는 다르게 콜백으로 함수가 전달되었을 때의 this를 구분해줘야 할 때가 있다.
그럴땐 아래와 같이 강제할 수 있다.

interface UIElement {
  // 아래 함수의 `this: void` 코드는 `this` 타입을 선언할 필요가 없다는 의미
  addClickListener(onclick: (this: void, e: Event) => void): void;
}

class Handler {
  info: string;
  onClick(this: Handler, e: Event) {
    // 위의 `UIElement` 인터페이스의 스펙에 `this`가 필요없다고 했지만 사용했기 때문에 에러가 발생
    this.info = e.message;
  }
}
let handler = new Handler();
uiElement.addClickListner(handler.onClick); // error

만약 UIElement 인터페이스의 스펙에 맞춰 Handler를 구현하려면 아래와 같이 변경한다.

class Handler {
  info: string;
  onClick(this: void, e: Event) {
    // `this`의 타입이 void이기 때문에 여기서 `this`를 사용할 수 없다.
    console.log('clicked');
  }
}
let handler = new Handler();
uiElement.addClickListner(handler.onClick);

인터페이스

인터페이스는 상호 간의 정의한 약속 혹은 규칙을 의미한다.
타입스크립트에서의 인터페이스는 보통 다음과 같은 범주에 대해 약속을 정의할 수 있다.

  • 객체의 스펙(속성과 속성의 타입)
  • 함수의 파라미터
  • 함수의 스펙(파라미터, 반환 타입 등)
  • 배열과 객체를 접근하는 방식
  • 클래스

인터페이스 맛보기

인터페이스에 대해 알아볼 수 있는 간단한 예제를 보자.

let person = { name: 'Capt', age: 28 };

function logAge(obj: { age: number }) {
  console.log(obj.age); // 28
}
logAge(person); // 28

logAge() 함수에는 받는 인자의 형태는 age를 속성으로 갖는 객체이다.
이렇게 인자를 받을 때 단순한 타입 뿐만 아니라 객체의 속성 타입까지 정의할 수 있다.

만약 여기서 인터페이스를 적용하면 아래와 같다.

interface personAge {
  age: number;
}

function logAge(obj: personAge) {
  console.log(obj.age);
}
let person = { name: 'Capt', age: 28 };
logAge(person);

이제는 logAge()의 인자가 좀 더 명시적으로 바뀌었다.
logAge()의 인자는 personAge 라는 타입을 가져야한다.

그리고 위 코드를 보고 다음과 같이 추론할 수 있다.
인터페이스를 인자로 받아 사용할 때 항상 인터페이스의 속성 갯수와 인자로 받는 객체의 속성 갯수를 일치시키지 않아도 된다.
다시 말해, 인터페이스에 정의된 속성, 타입의 조건만 만족한다면 객체의 속성 갯수가 더 많아도 상관 없다는 의미이다.
또한, 인터페이스에 선언된 속성 순서를 지키지 않아도 된다.

옵션 속성

인터페이스를 사용할 때 인터페이스에 정의되어 있는 속성을 모두 다 꼭 사용하지 않아도 된다.
이를 옵션 속성이라고 한다.

interface 인터페이스_이름 {
  속성?: 타입;
}

이처럼 속성의 끝에 ?를 붙인다.

interface CraftBeer {
  name: string;
  hop?: number;
}

let myBeer = {
  name: 'Saporo'
}

function brewBeer(beer: CraftBeer) {
  console.log(beer.name); // Saporo
}
brewBeer(myBeer);

코드를 보면 brewBeer() 함수에서 Beer 인터페이스를 인자의 타입으로 선언했음에도 불구하고, 인자로 넘긴 객체에는 hop 속성이 없다.
왜냐하면 hop을 옵션 속성으로 선언했기 때문이다.

옵션 속성의 장점

옵션 속성의 장점은 단순히 인터페이스를 사용할 때 속성을 선택적으로 적용할 수 있다는 것 뿐만 아니라 인터페이스에 정의되어 있지 않은 속성에 대해서 인지시켜줄 수 있다는 점이다.

interface CraftBeer {
  name: string;
  hop?: number;
}

let myBeer = {
  name: 'Saporo'
};
function brewBeer(beer: CraftBeer) {
  console.log(beer.brewery); // Error: Property 'brewery' does not exist on type 'Beer'
}
brewBeer(myBeer);

위에 보시는 것처럼 인터페이스에 정의되어 있지 않은 속성에 대해서 오류를 표시한다.
만약 아래와 같이 오탈자가 났다면 그것 역시 알려준다.

interface CraftBeer {
  name: string;
  hop?: number;
}

function brewBeer(beer: CraftBeer) {
  console.log(beer.nam); // Error: Property 'nam' does not exist on type 'Beer'
}

읽기 전용 속성

읽기 전용 속성은 인터페이스로 객체를 처음 생성할 때만 값을 할당하고 그 이후에는 변경할 수 없는 속성을 의미한다.
문법은 다음과 같이 readonly 속성을 앞에 붙인다.

interface CraftBeer {
  readonly brand: string;
}

인터페이스로 객체를 선언하고 나서 수정하려고 하면 아래와 같이 오류가 난다.

let myBeer: CraftBeer = {
  brand: 'Belgian Monk'
};
myBeer.brand = 'Korean Carpernter'; // error

읽기 전용 배열

배열을 선언할 때 ReadonlyArray<T> 타입을 사용하면 읽기 전용 배열을 생성할 수 있다.

let arr: ReadonlyArray<number> = [1, 2, 3];
arr.splice(0, 1); // error
arr.push(4); // error
arr[0] = 100; // error

위처럼 배열을 ReadonlyArray로 선언하면 배열의 내용을 변경할 수 없다.
선언한느 시점에만 값을 정의할 수 있으니 주의해서 사용해야 한다.

객체 선언과 관련된 타입 체킹

타입스크립트는 인터페이스를 이용하여 객체를 선언할 때 좀 더 엄밀한 속성 검사를 진행한다.

interface CraftBeer {
  brand?: string;
}

function brewBeer(beer: CraftBeer) {
  // ...
}
brewBeer({ brandon: 'what' }); // error: Object literal may only specify known properties, but 'brandon' does not exist in type 'CraftBeer'. Did you mean to write 'brand'?

위 코드를 보면 CraftBeer 인터페이스에는 brand라고 선언되어 있지만 brewBeer() 함수에 인자로 넘기는 myBeer 객체에는 brandon이 선언되어 있어 오탈자 점검을 요하는 오류가 난다.

만약 이런 타입 추론을 무시하고 싶다면 아래와 같이 선언한다.

let myBeer = { brandon: 'what' };
brewBeer(myBeer as CraftBeer);

그럼에도 불구하고 만약 인터페이스 정의하지 않은 속성들을 추가로 사용하고 싶을 때는 아래와 같은 방법을 사용한다.

interface CraftBeer {
  brand?: string;
  [propName: string]: any;
}

함수 타입

인터페이스는 함수의 타입을 정의할 때에도 사용할 수 있다.

interface login {
  (username: string, password: string): boolean;
}

함수의 인자의 타입과 반환 값의 타이블 정한다.

let loginUser: login;
loginUser = function(id: string, pw: string) {
  console.log('로그인 했습니다');
  return true;
}

클래스 타입

C#이나 자바처럼 타입스크립트에서도 클래스가 일정 조건을 만족하도록 타입 규칙을 정할 수 있다.

interface CraftBeer {
  beerName: string;
  nameBeer(beer: string): void;
}

class myBeer implements CraftBeer {
  beerName: string = 'Baby Guinness';
  nameBeer(b: string) {
    this.beerName = b;
  }
  constructor() {}
}

인터페이스 확장

클래스와 마찬가지로 인터페이스도 인터페이스 간 확장이 가능하다.

interface Person {
  name: string;
}
interface Developer extends Person {
  skill: string;
}
let fe = {} as Developer;
fe.name = 'josh';
fe.skill = 'TypeScript';

혹은 아래와 같이 여러 인터페이스를 상속받아 사용할 수 있다.

interface Person {
  name: string;
}
interface Drinker {
  drink: string;
}
interface Developer extends Person, Drniker {
  skill: string;
}
let fe = {} as Developer;
fe.name = 'josh';
fe.skill = 'TypeScript';
fe.drink = 'Beer';

하이브리드 타입

자바스크립트의 유연하고 동적인 타입 특성에 따라 인터페이스 역시 여러 가지 타입을 조합하여 만들 수 있다.
예를 들어, 다음과 같이 함수 타입이면서 객체 타입을 정의할 수 있는 인터페이스가 있다.

interface CraftBeer {
  (beer: string): string;
  brand: string;
  brew(): void;
}

function myBeer(): CraftBeer {
  let my = (function(beer: string) {}) as CraftBeer;
  my.brand = 'Beer Kitchen';
  my.brew = function() {};
  return my;
}

let brewedBeer = myBeer();
brewedBeer('My First Beer');
brewedBeer.brand = 'Pangyo Craft';
brewedBeer.brew();

이넘 (Enums)

이넘은 특정 값들의 집합을 의미하는 자료형이다.
예를 들면 아래와 같은 목록이 이넘이 될 수 있다.

나이키
아디다스
뉴발란스

이넘은 다른 프로그래밍 언어를 다뤄본 사람들에게 친숙한 타입이다.
타입스크립트에서는 문자형 이넘과 숫자형 이넘을 지원한다.

숫자형 이넘

타입스크립트에서 숫자형 이넘은 아래와 같이 선언한다.

enum Direction {
  Up = 1,
  Down,
  Left,
  Right
}

위와 같이 숫자형 이넘을 선언할 때 초기 값을 주면 초기 값부터 차례로 증가한다.

Up - 1
Down - 2
Left - 3
Right - 4

만약 아래와 같이 초기 값을 주지 않으면 0부터 차례로 1씩 증가한다.

enum Direction {
  Up, // 0
  Down, // 1
  Left, // 2
  Right // 3
}

숫자형 이넘 사용하기

이렇게 선언한 이넘은 아래와 같이 사용할 수 있다.

enum Response {
  No = 0,
  Yes = 1,
}

function respond(recipient: string, message: Response): void {
  // ...
}

respond('Captain Pangyo', Response.Yes);

그리고 숫자형 이넘에서 주의할 점은 선언할 때 만약 이넘 값에 다른 이넘 타입의 값을 사용하면 선언하는 이넘의 첫 번째 값에 초기화를 해줘야 한다는 점이다.

enum Wrong {
  A = getSomeValue(),
  B, // Error, 초기화가 필요합니다.
}

주의

바로 앞 문맥을 검증하기 위한 몇 가지 코드 짜서 문장 고칠 것

문자형 이넘

문자형 이넘은 앞에서 살펴본 이넘과 개념적으로는 거의 비슷하다.
다만 런타임에서의 미세한 차이가 있다.

일단 문자형 이넘은 이넘 값 전부 다 특정 문자 또는 다른 이넘 값으로 초기화 해줘야 한다.

enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}

또한 문자형 이넘에는 수자형 이넘과는 다르게 auto-incrementing이 없다.
대신 디버깅을 할 때 숫자형 이넘과 값은 가끔 불명확하게 나올 때가 있지만 문자형 이넘은 항상 명확한 값이 나와 읽기 편하다.

복합 이넘 (Heterogeneous Enums)

기술적으로 이넘에 문자와 숫자를 혼합하여 생성할 순 있다.

enum BooleanLikeHeterogeneousEnum {
  No = 0,
  Yes = 'YES',
}

하지만 이 방식은 권고하지 않는다.
최대한 같은 타입을 이루어진 이넘을 사용하자.

런타임 시점에서의 이넘 특징

이넘은 런타임시에 실제 객체 형태로 존재한다.

enum E {
  X, Y, Z
}

function getX(obj: { X: number }) {
  return obj.X;
}
getX(E); // 이넘 E의 X는 숫자이기 때문에 정상 동작

컴파일 시점에서의 이넘 특징

이넘이 런타임 시점에서는 실제 객체지만 keyof를 사용할 때 주의해야 한다.
일반적으로 keyof를 사용해야 되는 상황에서는 대신 keyof typeof를 사용하자.

enum LogLevel {
  ERROR, WARN, INFO, DEBUG
}

// 'ERROR' | 'WARN' | 'INFO' | 'DEBUG';
type LogLevelStrings = keyof typeof LogLevel;

function printImportant(key: LogLevelStrings, message: string) {
  const num = LogLevel[key];
  if (num <= LogLevel.WARN) {
    console.log('Log level key is: ', key);
    console.log('Log level value is: ', num);
    console.log('Log level message is: ', message);
  }
}
printImportant('ERROR', 'This is a message');

리버스 매핑(Reverse Mapping)

리버스 매핑은 숫자형 이넘에만 존재하는 특징이다.
이넘의 키(key)로 값(value)를 얻을 수 있고 값(value)로 키(key)를 얻을 수도 있다.

enum Enum {
  A
}
let a = Enum.A; // 키로 값을 획득 하기
let keyName = Enum[a]; // 값으로 키를 획득 하기

주의

위와 같은 리버스 매핑은 문자형 이넘에는 존재하지 않는다.

연산자를 이용한 타입 정의

Union Type

유니온 타입(Union Type)이란 자바스크립트의 OR 연산자 (||)와 같이 A이거나 B이다 라는 의미의 타입이다.

function logText(text: string | number) {
  // ...
}

위 함수의 파라미터 text에는 문자열 타입이나 숫자 타입이 모두 올 수 있다.
이처럼 | 연산자를 이용하여 타입을 여러 개 연결하는 방식을 유니온 타입 정의 방식이라고 부른다.

Union Type의 장점

유니온 타입의 장점은 아래 2개의 코드를 비교하면 바로 알 수 있다.

// any를 사용하는 경우
function getAge(age: any) {
  age.toFixe(); // Error, age의 타입이 any로 추론되기 때문에 숫자 관련된 API를 작성할 때 코드가 자동 완성되지 않는다.
  return age;
}

// 유니온 타입을 사용하는 경우
function getAge(age: number | string) {
  if (typeof age === 'number') {
    age.toFixed(); // 정상 작동, age의 타입이 `number`로 추론되기 때문에 숫자 관련된 API를 쉽게 자동완성 할 수 있다.
    return age;
  }
  if (typeof age === 'string') {
    return age;
  }
  return new TypeError('age ust be number or string');
}

이처럼 any를 사용하는 경우 마치 자바스크립트로 작성하는 것처럼 동작을 하고 유니온 타입을 사용하면 타입스크립트의 이점을 살리면서 코딩할 수 있다.

Intersection Type

인터섹션 타입(Intersection Type)은 여러 타입을 모두 만족하는 하나의 타입을 의미한다.

interface Person {
  name: string;
  age: number;
}
interface Developer {
  name: string;
  skill: number;
}
type Capt = Person & Developer;

위 코드는 Person 인터페이스 타입 정의와 Developer 인터페이스의 타입 정의를 & 연산자를 이용하여 합친 후 Capt 이라는 타입에 할당한 코드이다.
결과적으로 Capt의 타입은 아래와 같이 정의된다.

{
  name: string;
  age: number;
  skill: string;
}

이처럼 & 연산자를 이용해 여러 개의 타입 정의를 하나로 합치는 방식을 인터섹션 타입 정의 방식이라고 한다.

Union Type을 쓸 때 주의할 점

앞에서 유니온 타입과 인터섹션 타입을 살펴봤다.
아마 논리적으로 유니온 타입은 OR, 인터섹션은 AND라고 생각하는 사람들이 많을텐데 인터페이스와 같은 타입을 다룰 땐느 이와 같은 논리적 사고를 주의해야 한다.

interface Person {
  name: string;
  age: number;
}
interface Developer {
  name: string;
  skill: string;
}
function introduce(somone: Person | Developer) {
  someone.name; // O 정상 작동
  someone.age; // X 타입 오류
  someone.skill; // X 타입 오류
}

여기서 introduce() 함수의 파라미터 타입을 Person, Developer 인터페이스의 유니온 타입으로 정의 하였다.
유니온 타입은 A도 될 수 있고 B도 될 수 있는 타입이지라고 생각하면 파라미터의 타입이 Person도 되고 Developer도 될테니까 함수 안에서 당연히 이 인터페이스들이 제공하는 속성들인 ageskill를 사용할 수 있겠지라고 생각할 수 있다.
하지만, 타입스크립트 관점에서는 introduce() 함수를 호출하는 시점에 Person타입이 올지 Developer 타입이 올지 알 수가 없기 때문에 어느 타입이 들어오든 간에 오류가 안나는 방향으로 타입을 추론하게 된다.

const capt: Person = { name: 'capt', age: 100 }
introduce(capt); // 만약 `introduce` 함수 안에서 `someone.skill` 속성을 접근하고 있으면 함수에서 오류 발생
const tony: Developer = { name: 'tony', skill: 'iron making' };
introduce(tony); // 만약 `introduce` 함수 안에서 `someone.age` 속성을 접근하고 있으면 함수에서 오류 발생

결과적으로 introduce 함수 안에서는 별도의 타입 가드(Type Guard)를 이용하여 타입의 범위를 좁히지 않는 이상 기본적이고 PersonDeveloper 두 타입에 공통적으로 들어있는 속성인 name만 접근 할 수 있게 된다.

function introduce(someone: Person | Developer) {
  console.log(someone.name); // O 정상 작동
}

클래스

readonly

클래스의 속성에 readonly 키워드를 사용하면 아래와 같이 접근만 가능하다.

class Developer {
  readonly name: string;
  constructor(theName: string) {
    this.name = theName;
  }
}
let john = new Developer('john');
john.name = 'john'; // error! name is readonly.

이처럼 readonly를 사용하면 conructor() 함수에 초기 값 설정 로직을 넣어줘야 하므로 다음과 같이 인자에 readonly 키워드를 추가해서 코드를 줄일 수 있다.

class Developer {
  readonly name: string;
  constructor(readonly name: string) {
  }
}

Accessor

타입스크립트는 객체의 특정 속성의 접근과 할당에 대해 제어할 수 있다.
이를 위해선 해당 객체가 클래스로 생성한 객체여야 한다.

class Developer {
  name: string;
}
const josh = new Developer;
josh.name = 'Josh Bolton';

위 코드는 클래스로 생성한 객체의 name 속성에 Josh Bolton 이라는 값을 대입한 코드이다.
이제 josh라는 객체의 name 속성은 Josh Bolton이라는 값을 갖는다.

여기서 만약 name 속성에 제약 사항을 추가하고 싶으면 아래와 같이 getset을 활용한다.

class Developer {
  private name: string;
  
  get name(): string {
    return this.name;
  }
}

set name(newValue: string) {
  if (newValue && newValue.length > 5) {
    throw new Error('이름이 너무 깁니다.');
  }
  this.name = newValue;
}
const josh = new Developer();
josh.name = 'Josh Bolton'; // Error
josh.name = 'Josh';

TIP

get만 선언하고 set을 선언하지 않을 경우에는 자동으로 readonly로 인식된다.

Abstract Class

추상 클래스 (Abstract Class)는 인터페이스와 비슷한 역할을 하면서도 조금 다른 특징을 갖고 있다.
추상 클래스는 특정 크래스의 상속 대상이 되는 클래스이며 좀 더 상위 레벨에서 속성, 메서드의 모양을 정의한다.

abstract class Developer {
  abstract coding(): void; // 'abstract'가 붙으면 상속 받는 클래스에서 무조건 구현해야 함
  drink(): void {
    console.log('drink sth');
  }
}

class FrontEndDeveloper extends Developer {
  coding(): void {
    // Developer 클래스를 상속 받은 클래스에서 무조건 정의해야 하는 메서드
    console.log('develop web');
  }
  design(): void {
    console.log('design web');
  }
}
const dev = new Developer(); // error: cannot create an instance of an abstract class
const josh = new FrontEndDeveloper();
josh.config(); // develop web
josh.drink(); // drink sth
josh.design(); // design web

제네릭

제네릭(Generics)의 사전적 정의

제네릭은 C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 특징이다.
특히, 한가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용된다.

제네릭의 한 줄 정의와 예시

제네릭이란 타입을 마치 함수의 파라미터처럼 사용하는 것을 의미한다.

function getText(text) {
  return text;
}

위 함수는 text라는 파라미터에 값을 받아 text로 반환해준다.
hi10true 등 어떤 값이 들어가더라도 그대로 반환한다.

getText('hi'); // 'hi'
getText(10); // 10
getText(true); // true

이 관점에서 제네릭을 한 번 살펴보자.

fuction getText<T>(text: T): T {
  return text;
}

먼저 위 함수에서 제네릭 타입에 <string>이 되는 이유는 getText() 함수를 호출할 때 제네릭(함수에서 사용할 타입)값으로 string을 넘겼기 때문이다.

getText<string>();

그리고 나서 함수의 인자로 hi 라는 값을 아래와 같이 넘기게 되면

getText<string>('hi');

getText 함수는 아래와 같이 타입을 정의한 것과 같다.

function getText<string>(text: string): string {
  return text;
}

위 함수는 입력 값의 타입이 string이면섭 ㅏㄴ환 값 타입도 string이어야 한다.

제네릭을 사용하는 이유

function logText(text: string): string {
  return text;
}

위 코드는 인자를 하나 넘겨 받아 반환해주는 함수이다.
마치 리눅스의 echo 명령어와 같은 역할을 한다.
여기서 이 함수의 인자와 반환 값은 모두 string으로 지정되어 있지만 만약 여러 가지 타입으 허용하고 싶다면 아래와 같이 any를 사용할 수 있다.

function logText(text: any): any {
  return text;
}

이렇게 타입을 바꾼다고 해서 함수의 동작에 문제가 생기진 않는다.
다만, 함수의 인자로 어떤 타입이 들어갔고 어떤 값이 반환되는지 알수가 없다.
왜냐하면 any라는 타입은 타입 검사를 하지 않기 때문이다.

이러한 문제점을 해결할 수 있는 것이 제네릭이다.

function logText<T> (text: T): text: T {
  return text;
}

먼저 함수의 이름 바로 뒤에 <T>라는 코드를 추가했다.
그리고 함수의 인자와 반환 값에 모두 T라는 타입을 추가한다.
이렇게 되면 함수를 호출할 때 넘긴 타입에 대해 타입스크립트가 추정할 수 있게 된다.
따라서, 함수의 입력 값에 대한 타입과 출력 값에 대한 타입이 동일한지 검증할 수 있게 된다.

그리고 이렇게 선언한 함수는 아래와 같이 2가지 방법으로 호출할 수 있다.

// #1
const text = logText<string>('Hello Generic');
// #2
const text = logText('Hello Generic');

보통 두 번째 방법이 코드가 더 짧고 가독성이 좋기 때문에 흔하게 사용된다.
그렇지만 만약 복잡한 코드에서 두 번째 코드로 타입 추정이 되지 않는다면 첫번재 방법을 사용하면 된다.

제네릭 타입 변수

앞에서 배운 내용으로 제네릭을 사용하기 시작하면 컴파일러에서 인자에 타입을 넣어달라는 경고를 보게된다.

function logText<T>(text: T): T {
  return text;
}

만약 여기서 함수의 인자로 받은 값의 length를 확인하고 싶다면 어떻게 해야 할까?

function logText<T>(text: T): T {
  console.log(text.length); // Error: T doesn`t have .length
  return text;
}

위 코드를 반환하려고 하면 컴파일러에서 에러를 발생시킨다.
왜냐하면 text.length가 있다는 단서는 어디에 없기 때문이다.

다시 위 제네릭 코드의 의미를 살펴보면 함수의 인자와 반환 값에 대한 타입을 정하진 않았지만, 입력 값으로 어떤 타입이 들어왔고 반환 값으로 어떤 타입이 나가는지 알 수 있다.
따라서, 함수의 인자와 반환 값 타입에 마치 any를 지정한 것과 같은 동작을 한다는 것을 알 수 있다.
그래서 설령 인자에 number 타입을 넘기더라도 에러가 나진 않는다.
이러한 특성 때문에 현재 인자인 text에 문자열이나 배열이 들어와도 아직은 컴파일러 입장에서 .length를 허용할 순 없다.
왜냐하면 number가 들어왔을 때는 .length 코드가 유효하지 않기 때문이다.

그래서 이런 경우에는 아래와 같이 제네릭에 타입을 줄 수가 있다.

function logText<T>(text: T[]): T[] {
  console.log(text.length); // 제네릭 탕비이 배열이기 때문에 `length`를 허용함.
  return text;
}

위 코드가 기존의 제네릭 코드와 다른 점은 인자의 T[] 부분이다.
이 제네릭 함수 코드는 일단 T라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T를 받는다.
예를 들면, 함수에 [1, 2, 3]처럼 숫자로 이뤄진 배열을 받으면 반환 값으로 number를 돌려주는 것이다.
이런 방식으로 제네릭을 사용하면 꽤 유연한 방식으로 함수의 타입을 정의해줄 수 있다.

혹은 다음과 같이 좀 더 명시적으로 제네릭 타입을 선언할 수 있다.

function logText<T>(text: Array<T>): Array<T> {
  console.log(text.length);
  return text;
}

제네릭 타입

제네릭 인터페이스에 대해 알아보겠다.
아래의 두 코드는 같은 의미이다.

function logText<T>(text: T): T {
  return text;
}
// #1
let str: <T>(text: T) => T = logText;
// #2
let str: {<T>(text: T): T} = logText;

위와 같은 변형 방식으로 제네릭 인터페이스 코드를 다음과 같이 작성할 수 있다.

interface GenericLogTextFn() {
  <T>(text: T): T;
}
function logText<T>(text: T): T {
  return text;
}
let myString: GenericLogTextFn = logText; // Okay

위 코드에서 만약 인터페이스에 인자 타입을 강조하고 싶다면 아래와 같이 변경할 수 있다.

interface GenericLogTextFn<T> {
  (text: T): T;
}
function logText<T>(text: T): T {
  return text;
}
let myString: GenericLogTextFn<string> = logText;

이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성할 수 있다.
이넘(enum)과 네임스페아스(namespace)는 제네릭으로 생성할 수 없다.

제네릭 클래스

제네릭 클래스는 앞에서 살펴본 제네릭 인터페이스와 비슷하다.

class GenericMath<T> {
  pi: T;
  sum: (x: T, y: T) => T;
}

let math = new GenericMath<number>();

제네릭 클래스를 선언할 때 클래스 이름 오른쪽에 <T>를 붙인다.
그리고 해당 클래스로 인스턴스를 생성할 때 타입에 어떤 값이 들어갈 지 지정하면 된다.

조금 전에 살펴본 인터페이스처럼 제네릭 클래스도 클래스 안에 정의된 속성들이 정해진 타입으로 잘 동작하게 보장할 수 있다.

제네릭 제약 조건

앞에서 제네릭 타입 변수에서 살펴본 내용 말고도 제네릭 함수에 어느 정도 타입 힌트를 줄 수 있는 방법이 있다.

function logText<T>(text: T): T {
  console.log(text.length); // Error: T doesn`t have .length
  return text;
}

인자의 타입에 선언한 T는 아직 어떤 타입인지 구체적으로 정의하지 않았기 때문에 length 코드에서 오류가 난다.
이럴 때 만약 해당 타입을 정의하지 않고도 length 속성 정도는 허용하려면 아래와 같이 작성한다.

interface LengthWise {
  length: number;
}

function logText<T extends LengthWise>(text: T): T {
  console.log(text.length);
  return text;
}

위와 같이 작성하게 되면 타입에 대한 강제는 아니지만 lengthㅔ 대해 동작하는 인자만 넘겨받을 수 있게 된다.

logText(10); // Error, 숫자 타입에는 `length`가 존재하지 않으므로 오류 발생
logText({ length: 0, value: 'hi' }); // `text.length` 코드는 객체의 속성 접근과 같이 동작하므로 오류 없음

객체의 속성을 제약하는 방법

두 객체를 비교할 때도 제네릭 제약 조건을 사용할 수 있다.

function getProperty<T, O extends keyof T>(obj: T, key: O) {
  return obj[key];
}
let obj = {a: 1, b: 2, c: 3};

getProperty(obj, 'a'); // okay
getProperty(obj, 'z'); // error: 'z'는 'a', 'b', 'c' 속성에 해당하지 않음.

제네릭을 선언할 때 <O extends keyof T> 부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한하였다.

타입 추론(Type Inference)

타입 추론이란 타입스크립트가 코드를 해석해 나가는 동작을 의미한다.

타입 추론의 기본

타입스크립트가 타입 추론을 해나가는 과정은 다음과 같다.

let x = 3;

위와 같이 x에 대한 타입을 따로 지정하지 않더라도 일단 xnumber로 간주된다.
이렇게 변수를 선언하거나 초기화 할 때 타입이 추론된다.
이외에도 변수, 속성, 인자의 기본 값, 함수의 반환 값 등을 설정할 때 타입 추론이 일어난다.

가장 적절한 타입(Best Common Type)

타입은 보통 몇 개의 표현식(코드)을 바탕으로 타입을 추론한다.
그리고 그 표현식을 이용하여 가장 근접한 타입을 추론하게 되는데 이 가장 근접한 타입을 Best Common Type이라고 한다.

let arr = [0, 1, null];

위 변수 arr의 타입을 추론하기 위해서는 배열의 각 아이템을 살펴봐야 한다.
배열의 각 아이템의 타입은 크게 numbernull로 구분된다.
이 때 Best Common Type 알고리즘으로 다른 타입들과 가장 잘 호환되는 타입을 선정한다.

문맥상의 타이핑(Contextual Typing)

타입스크립트에서 타입을 추론하는 또 하나의 방식은 바로 문맥상으로 타입을 결정하는 것이다.
이 문맥상의 타이핑(타입 결정)은 코드의 위치(문맥)를 기준으로 일어난다.

예시 코드 1

window.onmousedown = function(mouseEvent) {
  console.log(mouseEvent.button); // <- OK
  console.log(mouseEvent.kangaroo); // <- Error
}

위 코드를 타입스크립트 검사기 관점에서 보면 window.onmousedown에 할당되는 함수의 타입을 추론하기 위해 window.onmousedown 타입을 검사한다.
타입 검사가 끝나고 나면 함수의 타입이 마우스 이벤트와 연관이 있다고 추론하기 때문에 mouseEvent 인자에 button 속성은 있지만 kangaroo 속성은 없다고 결론을 내린다.

예시 코드 2

window.onscroll = function(uiEvent) {
  console.log(uiEvent.button); // <- Error
}

앞의 예제와 마찬가지로 오른쪽의 함수는 window.onscroll에 할당되었기 때문에 함수의 인자 uiEventUIEvent으로 간주된다.
MouseEvent와는 다르게 button 속성이 없다고 추론한다. 그로므로 uiEvent.button에서 에러가 난다.

여기서 만약 문맥상 타이핑을 좀 더 이해하고자 한다면 아래와 같이 코드를 바꿔볼 수 있다.

const handler = function(uiEvent) {
  console.log(uiEvent.button); // <- OK
}

오른쪽 함수 표현식이 앞의 예제와 동일하지만 함수가 할당되는 변수만으로는 타입을 추정하기 어렵기 때문에 아무 에러가 나지 않는다.

주의

위 코드에서 --noImplicitAny 옵션을 사용하면 에러남

타입스크립트 타입 체킹

타입 체킹에 있어서 타입스크립트의 지향점은 타입 체크는 값의 형태에 기반하여 이루어져야 한다는 점이다.
이걸 Duck Typing 또는 Structural Subtyping 이라고 한다.

TIP

  • duck Typing: 객체의 변수 및 메서드의 집합이 객체의 타입을 결정하는 것을 의미. 동적 타이핑의 한 종류
  • Structural Subtyping: 객체의 실제 구조나 정의에 따라 타입을 결정하는 것을 의미.

타입 호환(Type Compatibility)이란?

타입 호환이란 타입스크립트 코드에서 특정 타입이 다른 타입에 잘 맞는지를 의미한다.

interface Ironman {
  name: string;
}

class Avengers {
  name: string;
}

let i: Ironman;
i = new Avengers(); // OK, because of structural typing

C#이나 Java였다면 위 코드에서 오류가 난다.
왜냐하면 Avengers 클래스가 명시적으로 Ironman 인터페이스를 상속받아 구현하지 않았기 때문이다.

하지만 위와 같은 코드가 타입스크립트에서 정상적으로 동작하는 이유는 자바스크립트의 작동 방식과 관련이 있다.
기본적으로 자바스크립트는 객체 리터럴이나 익명 함수 등을 사용하기 때문에 명시적으로 타입을 지정하는 것보다는 코드의 구조 관점에서 타입을 지정하는 것이 더 잘 어울린다.

구조적 타이핑 예시

구조적 타이핑(structural typing)이란 코드 구조 관점에서 타입이 서로 호환되는지의 여부를 판단하는 것이다.

interface Avengers {
  name: string;
}

let hero: Avengers;
// 타입스크립트가 추론한 y의 타입은 { name: string; location: string; }
let capt = { name: 'Captain', localtion: 'pangyo' };
hero = capt;

위 코드에서 capthero 타입에 호환될 수 있는 이유는 capt의 속성 중에 name이 있기 때문이다.
Avengers 인터페이스가 name 속성을 갖고 있기 때문에 captAvengers 타입에 호환될 수 있다.

함수를 호출할 때도 마찬가지이다.

function assemble(a: Avengers) {
  console.log('어벤져스 모여라', a.name);
}
// 위에서 정의한 capt 변수, 타입은 { name: string; location: string; }
assemble(capt);

capt 변수에 이미 name 속성 뿐만 아니라 location 속성도 있기 때문에 assemble 함수의 호출 인자로 넘길 수 있다.

Soundness란?

타입스크립트는 컴파일 시점에 타입을 추론할 수 없는 특정 타입에 대해서 일단 안전하다고 보는 특성이 있다.
이걸 "들리지 않는다(it is said to pnot be sound)"라고 표현한다.

Enum 타입 호환 주의 사항

이넘 타입은 number 타입과 호환되지만 이넘 타입끼리는 호환되지 않는다.

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green; // Error

Class 타입 호환 주의 사항

클래스 타입은 클래스 타입끼리 비교할 때 스태틱 멤버(static member)와 생성자(constructor)를 제외하고 속성만 비교한다.

class Hulk {
  handSize: number;
  constructor(name: string, numHand: number) {}
}

class Captain {
  handSize: number;
  constructor(numHand: number) {}
}

let a: Hulk;
let s: Captain;

a = s; // OK
s = a; // OK

Generics

제네릭은 제네릭 타입 간의 호환 여부를 판단할 때 타입 인자 <T>가 속성에 할당 되었는지를 기준으로한다.

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y; // OK, because y matches structure of x

위 인터페이스는 일단 속성(member 변수)이 없기 때문에 xy는 같은 타입으로 간주된다.
그런데 만약 아래와 같이 인터페이스에 속성이 있어서 제네릭의 타입 인자가 속성에 할당된다면 얘기는 다르다.

interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y; // Error, because x and y are not compatible

인터페이스 NotEmpty에 넘긴 제네릭 타입 <T>data 속성에 할당되었으므로 xy는 서로 다른 타입으로 간주된다.

타입 별칭 (Type Aliases)

타입 별칭은 특정 타입이나 인터페이스를 참조할 수 있는 타입 변수를 의미한다.

// string 타입을 사용할 때
const name: string = 'capt';

// 타입 별칭을 사용할 때
type MyName = string;
const name: MyName = 'capt';

위와 같이 string, number와 같은 간단한 타입 뿐만 아니라 interface 레벨의 복잡한 타입에도 별칭을 부여할 수 있다.

type Developer = {
  name: string;
  skill: string;
}

타입 별칭에 제네릭도 사용할 수 있다.

type User<T> = {
  name: T
}

타입 별칭의 특징

타입 별칭은 새로운 타입 값을 하나 생성하는 것이 아니라 정의한 타입에 대해 나중에 쉽게 참고할 수 있게 이름을 부여하는 것과 같다.
이러한 특징은 VSCode 상의 프리뷰 상태로 다른 타입과 어떤 차이점이 있는지 확인해볼 수 있다.

아래는 인터페이스로 선언한 타입을 프리뷰로 확인한 결과이다.

아래는 타입 별칭으로 선언한 타입을 프리뷰로 확인한 결과이다.

type vs interface

타입 별칭과 인터페이스의 가장 큰 차이점은 타입의 확장 가능/불가능 여부이다.
인터페이스는 확장이 가능한데 반해 타입 별칭은 확장이 불가능하다.
따라서, 가능한한 type 보다는 interface로 선언해서 사용하는 것을 추천한다.

TIP

좋은 소프트웨어는 언제나 확장이 용이해야 한다는 원칙에 따라 가급적 확장 가능한 인터페이스로 선언하면 좋다.

profile
Always happy coding 😊

0개의 댓글