Skip to content

Latest commit

 

History

History
917 lines (752 loc) · 34.5 KB

05_클래스와 인터페이스.md

File metadata and controls

917 lines (752 loc) · 34.5 KB

5장 클래스와 인터페이스


개요

  • TS 클래스를 컴파일하면 일반 자바스크립트 클래스가 되므로 믹스인(mixin) 같은 자바스크립트 표현식도 타입 안정성을 유지하며 사용할 수 있다.
  • 프로퍼티 초기자와 데코레이터 같은 TS 기능 일부는 자바스크립트에서도 지원하므로 실제 런타임 코드를 생성한다.
  • 반면 가시성 접근자, 인터페이스, 제네릭 등은 TS만의 고유 기능이므로 컴파일 타임에만 존재한다.

5-1 클래스와 상속

체스 엔진 만들기

  • 두 명이 체스를 둘 수 있는 API를 제공한다. 먼저 타입을 정의하자.

    // 체스 게임
    class Game {}
    // 체스 말
    class Piece {}
    // 체스 말의 좌표 집합
    class Position {}
    
    class King extends Piece {} // 킹
    class Queen extends Piece {} // 퀸
    class Bishop extends Piece {} // 비숍
    class Knight extends Piece {} // 나이트
    class Rook extends Piece {} // 룩
    class Pawn extends Piece {} // 폰
  • 모든 말은 색과 현재 위치 정보를 갖는다.

  • Piece 클래스에 색과 위치를 추가하자.

    • public: 어디에서나 접근할 수 있다.
    • protected: 이 클래스와 서브클래스의 인스턴스에서만 접근할 수 있다.
    • private: 이 클래스의 인스턴스에서만 접근할 수 있다.

    type Color = 'Black' | 'White';
    type File_ = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H';
    type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; // 1
    
    class Position {
      constructor(
        private file: File_, // 2
        private rank: Rank
      ) {}
    }
    
    class Piece {
      protected position: Position; // 3
      constructor(
        private readonly color: Color, // 4
        file: File_,
        rank: Rank
      ) {
        this.position = new Position(file, rank);
      }
    }
      • 색, 링크, 파일의 종류가 많지 않으므로 가질 수 있는 모든 값을 타입 리터럴로 직접 열거하여 타입 안정성을 어느 정도 확보할 수 있다.
      • 생성자의 provate접근 한정자는 자동으로 매개변수를 this에 할당하며(즉, filethis.file이 된다) 가시성은 private으로 설정한다.
      • 즉, Position 인스턴스 안의 코드는 이 매개변수를 읽고 쓸 수 있지만 Position 인스턴스 외부에서는 접근할 수 없다.
      • Position 인스턴스끼리는 다른 인스턴스의 private 멤버에 접근할 수 있다.(다른 클래스의 인스턴스는 심지어 Position의 서브클래스조차도 비공개 멤버에 접근할 수 없다.)
      • 인스턴스 변수 positionprotected로 선언했다.
      • protectedprivate처럼 프로퍼티를 this에 할당하지만 private과 달리 Piece의 인스턴스와 Piece의 서브클래스 인스턴스 모두에 접근을 허용한다.
      • position을 선언하면서 할당은 하지 않았으므로 Piece의 생성자 함수에서 값을 할당해야 한다.
      • 생성자에서도 값을 할당하지 않으면 TS는 변수가 명확하게 할당되지 않았다고 불평한다.
      • 이는 변수의 타입을 T라고 선언했지만 실제로는 프로퍼티 초기자나 생성자에서 값을 할당하지 않아 T | undefined타입으로 결정되므로 position의 타입을 Position 또는 undefined가 될 수 있도록 시그니처를 바꿔야 한다.
      • new Piececolor, file, rank 세 개의 매개변수를 받는다.
      • color에 두 가지 한정자를 추가했는데 privatecolorthis로 할당해서 Piece의 인스턴스에서만 이 변수에 접근할 수 있게 만들며, readonly는 초기에 값을 할당한 다음에는 더 이상 값을 덮어쓸 수 없게 한다.

  • 접근 한정자를 이용해 내부 구현 정보를 너무 많이 공개하지 않고 잘 정의된 API만 노출하도록 클래스를 설계할 수 있다.

  • 여기서는 사용자가 Piece 인스턴스를 직접 생성하지 못하게 막고 대신 Queen이나 BishopPiece 클래스를 상속받은 클래스를 통해서만 인스턴스화할 수 있도록 허용할 것이다.
    abstract 키워드를 이용해 타입 시스템이 이 규칙을 강제하도록 설정할 수 있다.

    // ...
    abstract class Piece {
      constructor(
    //...
  • Piece를 직접 인스턴스화하려고 시도하면 TS가 다음과 같이 에러를 발생시킨다.

    new Piece('White', 'E', 1); // 에러 TS2511: 추상 클래스의 인스턴스는 생성할 수 없음
  • abstract 키워드는 해당 클래스를 바로 인스턴스화할 수 없음을 의미할 뿐 필요한 메서드를 추상 클래스에 자유롭게 추가할 수 있다.

    // ...
    abstract class Piece {
      // ...
      moveTo(position: Position) {
        this.position = position;
      }
      abstract canMoveTo(position: Position): boolean;
    }
    • canMoveTo라는 메서드를 주어진 시그니처와 호환되도록 구현해야 함을 하위 클래스에 알린다.
    • Piece를 상속받았으나 canMoveTo 메서드를 구현하지 않으면 컴파일 타임에 타입 에러가 발생한다.(추상 클래스를 구현할 때는 추상 메서드도 반드시 구현해야 한다.)
    • moveTo의 기본 구현을 포함한다. (public)
  • canMoveToKing 클래스를 구현해 넣어보자.

    class Position {
      // ...
      distanceFrom(position: Position) {
        // 말의 거리를 쉽게 계산하는 메서드도 추가
        return {
          rank: Math.abs(position.rank - this.rank),
          file: Math.abs(position.file.charCodeAt(0) - this.file.charCodeAt(0)),
        };
      }
    }
    
    class King extends Piece {
      canMoveTo(position: Position) {
        let distance = this.position.distanceFrom(position);
        return distance.rank < 2 && ditsance.file < 2;
      }
    }
  • 새 게임을 만들 때 자동으로 보드와 말을 만든다.

    class Game{
      private pieces = Game.makePieces()
      private static makePieces() {
        return [
          // Kings
          new King('White', 'E', 1)
          new King('Black', 'E', 8)
          // Queens, Bishop, ...
        ]
      }
    }
  • RankFile_의 타입을 엄격하게 설정했으므로 J 같은 미리 지정되지 않은 문자나 12 같이 범위를 벗어나는 숫자를 입력하면 에러를 발생시킨다.

  • 요약하자면 다음과 같다.

    • class 키워드로 클래스를 선언한 후 extends 키워드로 다른 클래스를 상속받을 수 있다.
    • 클래스는 구체 클래스와 추상 클래스로 분류된다. 추상 클래스는 추상 메서드와 추상 프로퍼티를 가질 수 있다.
    • 메서드는 3가지 한정자(private, protected, public(default))중 한 가지 한정자를 가질 수 있다. 메서드는 인스턴스 메서드와 정적 메서드 두 가지로 구분된다.
    • 인스턴스 프로퍼티를 선언할 때 readonly를 추가할 수 있다.

5-2 super

  • 자식 클래스가 부모 클래스에 정의된 메서드를 오버라이드하면(예: Queen, Piece 둘 다 take 메서드를 구현하는 상황) 자식 인스턴스는 super를 이용해 부모 버전의 메서드를 호출할 수 있다.(예: super.take)
  • TS는 두 가지 super 호출을 지원한다.
    • super.take 같은 메서드 호출
    • 생성자 함수에서만 호출할 수 있는 super()라는 특별한 타입의 생성자 호출.
      • 자식 클래스에 생성자 함수가 있다면 super()를 호출해야 부모 클래스와 정상적으로 연결된다.

5-3 this를 반환 타입으로 사용하기

  • this를 타입으로도 사용할 수 있다.
  • 클래스에 정의할 때라면 메서드의 반환 타입을 지정할 때 this 타입을 유용하게 활용할 수 있다.
  • ES6 Set 자료구조를 두 가지 연산만 지원하도록 간단하게 구현해보자.
    class Set {
      has(value: number): boolean {
        // ...
      }
      add(value: number): Set {
        // add를 호출하면 Set 인스턴스 반환
        // ...
      }
    }
  • 이번엔 Set을 상속받는 서브클래스를 만들어보자.
  • Setadd 메서드는 여전히 Set을 반환하도록 오버라이드한다.
    class MutableSet extends Set {
      delete(value: number): boolean {
        // ...
      }
      add(value: number): MutableSet {
        // ...
      }
    }
  • 서브클래스는 this를 반환하는 모든 메서드의 시그니처를 오버라이드해야 한다.(귀찮은 작업)
  • 다음처럼 반환 타입을 this로 지정하면 이 작업을 TS가 알아서 해준다.
    class Set {
      has(value: number): boolean {
        // ...
      }
      add(value: number): this {
        // add를 호출하면 this 인스턴스 반환
        // ...
      }
    }
  • 이제 서브클래스에서도 서브클래스의 인스턴스를 가리키므로 오버라이드 할 필요가 없다.

5-4 인터페이스

  • 클래스는 인터페이스를 통해 사용할 때가 많다.

  • 인터페이스를 사용하면 타입을 더 깔금하게 정의할 수 있다.

  • 타입 별칭과 인터페이스는 문법만 다를 뿐 거의 같은 기능을 수행한다.(마치 함수 표현식과 함수 구현의 차이와 비슷)

    type Sushi = {
      calories: number;
      salty: boolean;
      tasty: boolean;
    };
    type interface = {
      calories: number;
      salty: boolean;
      tasty: boolean;
    };
  • 타입을 조합하기 시작하면 차이점이 있다.

    type Food = {
      calories: number;
      tasty: boolean;
    };
    
    type Sushi = Food & {
      salty: boolean;
    };
    
    type Cake = Food & {
      sweet: boolean;
    };
    interface Food = {
      calories: number;
      tasty: boolean;
    };
    
    interface Sushi extends Food {
      salty: boolean;
    };
    
    interface Cake extends Food {
      sweet: boolean;
    };

    인터페이스가 반드시 다른 인터페이스를 상속받아야 하는 것은 아니다.
    사실 인터페이스는 객체 타입, 클래스, 다른 인터페이스 모두를 상속받을 수 있다.

    1. 타입 별칭은 더 일반적이어서 타입 별칭의 오른편에는 타입 표현식(타입 그리고 &,| 등의 타입 연산자)을 포함한 모든 타입이 등장할 수 있다. 반면 인스턴스의 오른편에는 반드시 형태가 나와야한다. 예를 들어 다음 타입 별칭 코드는 인터페이스로 다시 작성할 수 없다.

      type A = number;
      type B = A | string;
    2. 인터페이스를 상속할 때 TS는 상속받는 인터페이스의 타입에 상위 인터페이스를 할당할 수 있는지를 확인한다.

      interface A {
        good(x: number): string;
        bad(x: number): string;
      }
      
      interface B extends A {
        good(x: string | number): string;
        bad(x: string): string; // 에러 TS2430: 인터페이스 'B'는 인터페이스 'A'를 올바르게 상속받지 않음 'number' 타입은 'string' 타입에 할당할 수 없음
      }
      • 인터섹션 타입을 사용하면 상황이 달라진다. 위의 예에서 인터페이스는 타입별칭으로 바꾸고 extends는 인터섹션(&)으로 바꾸면 TS는 확장하는 타입을 최대한 조합하는 방향으로 동작한다.
      • 결과적으로 컴파일 에러가 발생하지 않고 bad를 오버로드한 시그니처가 만들어진다.
    3. 이름과 범위가 같은 인터페이스가 여러 개 있다면 이들이 자동으로 합쳐진다.(타입 별칭이 여러개라면 컴파일 타임 에러가난다.) 이를 선언 합침이라 부른다.


5-4-1 선언 합침

  • 선언 합침(declaration merging)은 같은 이름으로 정의된 여러 정의를 자동으로 합치는 TS의 기능이다.
interface User {
  name: string;
}

interface User {
  age: number;
}

let a: User = {
  name: 'Ashley',
  age: 30,
};
  • 위 코드를 타입 별칭으로 표현하면 에러가 일어난다.
  • 한편 인터페이스끼리는 충돌해서는 안 된다. 한 타입의 프로퍼티는 T와 다른 타입의 프로퍼티 U가 동일하지 않다면 에러가 발생한다.
    interface User {
      age: string;
    }
    interface User {
      age: number; // 에러 TS2717: 다른 프로퍼티 선언도 같은 타입을 가져야 함
    }
  • 제네릭을 선언한 인터페이스들의 경우 제네릭들의 선언 방법과 이름까지 똑같아야 합칠 수 있다.
    interface User<Age extends number> {
      // 에러 TS2428: 'User'의 모든 선언은 같은 타입 매개변수를 가져야 함
      age: Age;
    }
    interface User<Age extends string> {
      age: Age;
    }
  • 이 경우 두 타입이 서로 동일할 뿐 아니라 할당할 수 있는지까지 확인하는 보기 드문 상황.

5-4-2 구현

  • 클래스를 선언할 때 implements라는 키워드를 이용해 특정 인터페이스를 만족시킴을 표현할 수 있다.
  • 다른 명시적인 타입 어노테이션처럼 implements로 타입 수준의 제한을 추가하면 구현에 문제가 있을 때 어디가 잘못되었는지 쉽게 파악할 수 있다.
  • 또한 어댑터(adapter), 팩토리(factory), 전략(strategy) 등 흔한 디자인 패턴을 구현하는 대표적인 방식이기도 하다.

interface Animal {
  eat(food: string): void;
  sleep(hours: number): void;
}

class Cat implements Animal {
  eat(food: string) {
    console.info('Ate some', food, '. Mmm!');
  }
  sleep(hours: number) {
    console.info('Slept for', hours, 'hours!');
  }
}
  • CatAnimal이 선언하는 모든 메서드를 구현해야 하며, 필요하다면 메서드나 프로퍼티를 추가로 구현할 수 있다.

  • 인터페이스로 인스턴스 프로퍼티를 정의할 수 있지만 가시성 한정자는 선언할 수 없으며 static 키워드도 사용할 수 없다. 객체 안의 객체 타입 처럼 인스턴스 프로퍼티를 readonly로 설정할 수 있다.

    interface Animal {
      readonly name: string;
      eat(food: string): void;
      sleep(hours: number): void;
    }
  • 한 클래스가 하나의 인터페이스만 구현할 수 있는 것은 아니며 필요하면 여러 인터페이스를 구현할 수 있다.

    interface Animal {
      readonly name: string;
      eat(food: string): void;
      sleep(hours: number): void;
    }
    
    interface Feline {
      meow(): void;
    }
    
    class Cat implements Animal, Feline {
      name = 'Whiskers';
      eat(food: string) {
        console.info('Ate some', food, '. Mmm!');
      }
      sleep(hours: number) {
        console.info('Slept for', hours, 'hours!');
      }
      meow() {
        console.info('Meow');
      }
    }
  • 이 모든 기능은 완전한 타입 안전성을 제공한다.


5-4-3 인터페이스 구현 vs. 추상 클래스 상속

  • 둘은 아주 비슷하지만, 인터페이스가 더 범용으로 쓰이며 가벼운 반면, 추상 클래스는 특별한 목적과 풍부한 기능을 갖는다는 점이 다르다.
    • 인터페이스
      • 인터페이스는 형태를 정의하는 수단이다.
      • 인터페이스는 아무런 자바스크립트 코드를 만들지 않으며 컴파일 타임에만 존재한다.
    • 추상 클래스
      • 추상 클래스는 오직 클래스만 정의할 수 있다.
      • 추상 클래스는 런타임의 자바스크립트 클래스 코드를 만든다.
      • 인터페이스에서 제공되지 않는 기능을 지원한다.(생성자, 기본 구현, 접근 한정자)
  • 여러 클래스에서 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 형태를 정의하는 것이 목적이라면 인터페이스를 사용하자.

5-5 클래스는 구조 기반 타입을 지원한다

  • TS는 클래스를 비교할 때 다른 타입과 달리 이름이 아니라 구조를 기준으로 삼는다.

  • 클래스는 자신과 똑같은 프로퍼티와 메서드를 정의하는 기존의 일반 객체를 포함해 클래스의 형태를 공유하는 다른 모든 타입과 호환된다.

  • 예를들어, Zebra를 인수로 받는 함수에 Poodle을 전달한다고 해서 반드시 에러를 발생시키는 것은 아니기 때문이다.

    class Zebra {
      trot() {
        // ...
      }
    }
    
    class Poodle {
      trot() {
        // ...
      }
    }
    
    function ambleAround(animal: Zebra) {
      animal.trot();
    }
    
    let zebra = new Zebra();
    let poodle = new Poodle();
    
    ambleAround(zebra); // OK
    ambleAround(poodle); // OK
  • 클래스에 private이나 protected 필드가 있고, 할당하려는 클래스나 서브클래스의 인스턴스가 아니라면 할당할 수 없다고 판정한다.

    class A {
      private x = 1;
    }
    
    class B extends A {}
    
    function f(a: A) {}
    
    f(new A()); // OK
    f(new B()); // OK
    
    f({ x: 1 }); // 에러 TS2345: 인수  '{x: number}' 타입은 매개변수 'A' 타입에 할당할 수 없음
    // 'A'의 'x' 프로퍼티는 private이지만 '{x: number}'는 private이 아님

5-6 클래스는 값과 타입을 모두 선언한다

  • TS의 거의 모든 것은 값 아니면 타입이다.

    // 값
    let a = 1999;
    function b() {}
    
    // 타입
    type a = number;
    interface b {
      (): void;
    }
  • 값과 타입은 별도의 네인 스페이스에 존재한다.

  • 어떻게 사용하는지를 보고 이름 값 또는 타입으로 해석한다.

    // ...
    if (a + 1 > 3) { ... } // 문맥상 값 a로 추론함
    
    let x: a = 3; // 문맥상 타입 a로 추론함
  • 덕분에 컴패니언 타입 같은 멋진 기능을 구현할 수 있다(6-3-4 컴패니언 객체 패턴)

  • 한편 클래스와 열거형, 이들은 타입 네임스페이스에 타입을, 값 네임스페이스에 값을 동시에 생성한다는 점에서 특별하다.

    class C {}
    let c: C = // 1 문맥상 C는 C 클래스의 인스턴스 타입을 가리킨다.
      new C(); // 2 문맥상 C는 값 C를 가리킨다.
    
    enum E {
      F,
      G,
    }
    let e: E = E.F; // 3 문맥상 E는 E 열거형의 타입을 가리킨다. // 4 문맥상 E는 값 E를 가리킨다.
  • 클래스를 다룰 때 "이 변수는 이 클래스의 인스턴스여야 한다"라고 표현할 수 있는 방법이 필요한데, 이는 열거형도 마찬가지다.

  • 클래스와 열거형은 타입 수준에서 타입을 생성하기 때문에 'is-a'관계를 쉽게 표현할 수 있다.

  • 런타임에 new로 클래스를 인스턴스화하거나, 클래스의 정적 메서드를 호출하거나, 메타 프로그래밍하거나, instanceof 연산을 수행하려면 클래스의 값이 필요하다.

  • 이전 예제에서는 C 클래스의 인스턴스를 가리켰다. 만약 C 클래스 자체를 가리키려면 typeof 키워드를 사용하면된다.

    type State = {
      [key: string]: string;
    };
    
    class StringDatabase {
      state: State = {};
      get(key: string): string | null {
        return key in this.state ? this.state[key] : null;
      }
      set(key: string, value: string): void {
        this.state[key] = value;
      }
      static from(state: State) {
        let db = new StringDatabase();
        for (let key in state) {
          db.set(key, state[key]);
        }
        return db;
      }
    }
  • StringDatabase의 인스턴스 타입은 다음과 같다.

    interface StringDatabase {
      state: State;
      get(key: string): string | null;
      set(key: string, value: string): void;
    }
  • 다음은 typeof StringDatabase의 생성자 타입이다.

    interface StringDatabaseConstructor {
      new (): StringDatabase;
      from(state: State): StringDatabase;
    }
  • StringDatabaseConstructor.from이라는 한 개의 메서드를 포함하며 newStringDatabase 인스턴스를 반환한다. 두 인터페이스를 합치면 StringDatabase 클래스 생성자와 인스턴스가 완성된다.

  • new() 코드를 생성자 시그니처라 부르며, 이는 new 연산자로 해당 타입을 인스턴스화할 수 있음을 정의하는 타입스크립트의 방식이다.

  • 앞서 봤듯 TS는 구조를 기반으로 타입을 구분하기 때문에 이 방식이 클래스가 무엇인지를 기술하는 최선이다.

  • 앞의 예는 인수를 전혀 받지 않는 생성자이지만 인수를 받는 생성자도 선언할 수 있다.

  • 예를 들어 선택적으로 초기 상태를 받도록 수정한 모습이다.

    class StringDatabase {
      constructor(public state: State = {}) {}
      // ...
    }
    
    interface StringDatabaseConstructor {
      new (state?: state): StringDatabase;
      from(state: state): StringDatabase;
    }
  • 클래스 정의는 용어는 값 수준과 타입 수준으로 생성할 뿐 아니라, 타입 수준에서는 두 개의 용어를 생성했다. 하나는 클래스의 인스턴스를 가리키며, 다른 하나는 클래스 생성자 자체를 가리킨다.


5-7 다형성

  • 함수와 타입처럼, 클래스와 인터페이스도 기본값과 상한/하한 설정을 포함한 다양한 제네릭 타입 매개변수 기능을 지원한다.

  • 제네릭 타입의 범위는 클래스나 인터페이스 전체가 되게 할 수도 있고 특정 메서드로 한정할 수도 있다.

    class MyMap<K, V> {
      // 1
      constructor(initialKey: K, initialValue: V) {
        // ... // 2
      }
      get(key: K): V {
        // ... // 3
      }
      set(key: K, value: V): void {
        // ...
      }
      merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1> {
        // ... // 4
      }
      static of<K, V>(k: K, v: V): MyMap<K, V> {
        // ... // 5
      }
    }
    1. class와 제네릭을 선언했으므로 클래스 전체에서 타입을 사용할 수 있다. MyMap의 모든 인스턴스 메서드와 인스턴스 프로퍼티에서 KV를 사용할 수 있다.
    2. constructor에는 제네릭 타입을 선언할 수 없음을 기억하자. constructor 대신 class 선언에 사용해야 한다.
    3. 클래스로 한정된 제네릭 타입은 클래스 내부의 어디에서나 사용할 수 있다.
    4. 인스턴스 메서드는 클래스 수준 제네릭을 사용할 수 있으며 자신만의 제네릭도 추가로 선언할 수 있다.
    5. 정적 메서드는 클래스의 인스턴스 변수에 값 수준에서 접근할 수 없듯이 클래스 수준의 제네릭을 사용할 수 없다. 따라서 of는 1에서 선언한 KV에 접근할 수 없고 자신만의 KV를 직접 선언했다.
  • 인터페이스에서도 제네릭을 사용할 수 있다.

    interface MyMap<K, V> {
      get(key: K): V;
      set(key: K, value: V): void;
    }
  • 함수와 마찬가지로 제네릭에 구체 타입을 명시하거나 TS가 타입으로 추론하도록 할 수 있다.

    let a = new MyMap<string, number>('k', 1);
    let b = new MyMap('k', true);
    
    a.get('k');
    b.set('k', false);

5-8 믹스인

  • 자바스크립트나 타입스크립트는 traitmixin 키워드를 제공하지 않지만 손쉽게 직접 구현할 수 있다.

  • 두 키워드 모두 둘 이상의 클래스를 상속받는 다중 상속(multiple inheritance)과 관련된 기능을 제공하며, 역할 지향 프로그래밍(role-oriented programming)을 제공한다.

  • 역할 지향 프로그래밍에서는 "이것은 shape이에요"라고 표현하는 대신 "측정할 수 있어요", "네 개의 면을 갖고 있어요"처럼 속성을 묘사하는 방식을 사용한다.

  • 즉, 'is-a'관계 대신 'can', 'has-a' 관계를 사용한다.

  • 믹스인이란 동작과 프로퍼티를 클래스로 혼합(mix)할 수 있게 해주는 패턴으로, 다음 규칙을 따른다

    • 상태를 가질 수 있다(예: 인스턴스 프로퍼티).
    • 구체 메서드만 제공할 수 있다(추상 메서드는 안됨).
    • 생성자를 가질 수 있다(클래스가 혼합된 순서와 같은 순서로 호출됨).
  • 예를 들어 TS 클래스의 디버깅 라이브러리 EZDebug 설계한다고 하자.

    class User {
      // ...
    }
    // 사용예
    User.debug(); // 'User({"id": 3, "name": "Emma Gluzman"})' 로 평가
  • withEZDebug라는 믹스인을 이용해 이 기능을 구현한다.

    type ClassConstructor = new (...args: any[]) => {}; // 1
    
    // 2
    function withEZDebug<C extends ClassConstructor>(Class: C) {
      // 3
      return class extends Class {
        // 4
        constructor(...args: any[]) {
          super(...args); // 5
        }
      };
    }
    1. new로 만들 수 있는 모든 것을 생성자라고 규정한다. 또한 생성자에 어떤 타입의 매개변수가 올지 알 수 없으므로 임의의 개수의 any 타입 인수를 받을 수 있게 지정했다.
    2. 한 개의 타입 매개변수 C만 받도록 withEZDebug 믹스인을 선언했다. extends로 최소한 클래스 생성자여야하도록 강제하고, withEZDebug의 반환 타입은 C와 새로운 익명 클래스의 교집합이며 TS를 이를 추론하도록 했다.
    3. 믹스인은 생정자를 인수로 받아 생성자를 반환하는 함수이므로 익명 클래스 생성자를 반환했다.
    4. 이 생성자는 최소한 우리가 전달한 클래스가 받는 인수를 받을 수 있어야 한다. 하지만 어떤 클래스를 전달할 지 모름으로 임의의 개수 any 타입을 받도록 구현했다.
    5. 마지막으로 이 익명 클래스는 다른 클래스를 상속받으므로 Class의 생성자를 호출해야 한다는 사실을 기억하자.
      (constructor에 아무런 로직이 없으면 4,5의 코드를 생략할 수 있다.)
  • .debug를 호출하면 클래스의 생성자명과 인스턴스 값을 출력해야한다.

    type ClassConstructor = new (...args: any[]) => {};
    
    function withEZDebug<C extends ClassConstructor>(Class: C) {
      return class extends Class {
        debug() {
          let Name = this.constructor.name;
          let value = this.getDebugValue();
          return Name + '(' + JSON.stringify(value) + ')';
        }
      };
    }
  • 기존의 클래스를 받지 않고 제네릭 타입을 이용하면 withEZDebug로 전달한 클래스가 .getDebugValue 메서드를 정의하게 강제할 수 있다.

    // 1. 제네릭 타입 매개변수 추가
    type ClassConstructor<T> = new (...args: any[]) => T;
    
    // 2. 형태 타입 C를 ClassConstructor에 연결함으로써
    // withEZDebug로 전달한 생성자가 .getDebugValue 메서드를 정의하도록 강제했다.
    function withEZDebug<C extends ClassConstructor<{ getDebugValue(): object }>>(
      Class: C
    ) {
      // ...
    }
  • 이 디버깅 라이브러리를 사용하는 예시

    class HardToDebugUser {
      constructor(
        private id: number,
        private firstName: string,
        private lastName: string
      ) {}
      getDebugValue() {
        return {
          id: this.id,
          name: this.firstName + ' ' + this.lastName,
        };
      }
    }
    
    let User = withEZDebug(HardToDebugUser);
    let user = new User(3, 'Emma', 'Gluzman');
    user.debug(); // 'HardToDebugUser({"id": 3, "name": "Emma Gluzman})'로 평가
  • 필요한 수의 믹스인을 클래스에 제공함으로 더 풍부한 동작을 제공할 수 있으며 타입 안정성도 보장된다.

  • 믹스인은 동작을 캡슐화할 뿐 아니라 동작을 재사용할 수 있도록 도와준다.


5-9 데코레이터

  • 데코레이터(decorator)는 TS의 실험적인 기능으로 클래스, 클래스 메서드, 프로퍼티, 메서드 매개변수를 활용한 메타 프로그래밍에 깔끔한 문법을 제공한다.

  • 데코레이터는 장식하는 대상의 함수를 호출하는 기능을 제공하는 문법이다.

    TSC 플래그: experimentalDecorators

    데코레이터는 실험 단계의 기능으로 해당 플래그를 따로 설정해야 사용할 수 있다.

    @serializable
    class APIPayload {
      getValue(): Payload {
        // ...
      }
    }
  • 클래스 데코레이터인 @serializableAPIPayload 클래스를 감싸고 있으며 선택적으로 이를 대체하는 새 클래스를 반환한다.

  • 데코레이터를 사용하지 않고 같은 기능을 구현할 수도 있다.

    let APIPayload = serializable(class APIPayload) {
      getValue(): Payload{
        // ...
      }
    }
  • TS는 데코레이터 타입 각각에 대해 주어진 이름 범위에 존재하는 함수와 해당 데코레이터 타입에 요구되는 시그니처를 필요로 한다. (p. 128, 표 5-1)

  • TS가 기본으로 제공하는 데코레이터는 없다. 직접 구현하거나 NPM으로 설치해야 한다.

  • 데코레이터 기능이 더 완벽해지기 전까지는 일반 함수를 사용할 것을 권한다.

  • 자세한 정보는 공식 문서를 확인하자.


5-10 final 클래스 흉내내기

  • final 키워드는 클래스나 메서드를 확장하거나 오버라이드할 수 없게 만드는 기능이다.
  • TS에서는 비공개 생성자(private constructor)로 final 클래스를 흉내낼 수 있다.
    class MessageQueue {
      private constructor(private messages: string[]) {}
    }
  • 생성자를 private으로 선언하면 new로 인스턴스를 생성하거나 클래스를 확장 할 수 없게 된다.
  • 클래스 상속만 막아야 하지만 비공개 생성자를 이용하면 클래스를 인스턴스화하는 기능도 같이 사라진다.
  • 반면 final 클래스는 상속만 막을 뿐 인스턴스는 정상적으로 만들 수 있다.
    class MessageQueue {
      private constructor(private messages: string[]) {}
      static create(message: string[]) {
        return new MessageQueue(messages);
      }
    }

5-11 디자인 패턴

5-11-1 팩토리 패턴

  • 팩토리 패턴은 어떤 객체를 만들지 전적으로 팩토리에 위임한다.

  • 신발 팩토리를 만들어보자. 먼저 Shoe 타입을 정의하고 세 종류의 신발을 구현한다.

    type Shoe = {
      purpose: string;
    };
    
    class BalletFlat implements Shoe {
      purpose = 'dancing';
    }
    
    class Boot implements Shoe {
      purpose = 'woodcutting';
    }
    
    class Sneaker implements Shoe {
      purpose = 'walking';
    }
    
    let Shoe = {
      // 1
      create(type: 'balletFlat' | 'boot' | 'sneaker'): Shoe {
        // 2
        switch (type) {
          case 'balletFlat':
            return new BalletFlat();
          case 'boot':
            return new Boot();
          case 'sneaker':
            return new Sneaker();
        }
      },
    };
    1. type을 유니온 타입으로 지정해서 .create의 타입 안정성을 최대한 강화했다.
    2. switch문을 이용해 누락된 Shoe 타입이 없는지 TS가 쉽게 확인할 수 있게 한다.
  • 컴패니언 객체 패턴("6-3-4 컴패니언 객체 패턴" 참고)으로 타입 Shoe와 값 Shoe를 같은 이름으로 선언했다.(TS는 값과 네임스페이스를 따로 관리)

  • 이 팩토리를 이용하려면 그저 .create를 호출하면 된다.

    Shoe.create('boot'); // Shoe
  • 이 코드를 발전시켜 'boot'를 전달하면 Boot을 반환함을 드러내개끔 Shoe.create의 타입 시그니처를 수정할 수 있다. 하지만 그러면 팩토리 패턴이 제공하는 추상화 규칙을 깨는 결과를 초래한다.
    (호출자는 팩토리가 특정 인터페이스를 만족하는 클래스를 제공할 것이라는 사실만 알 뿐 어떤 구체 클래스가 이 일을 하는지 알 수 없어야 한다.)


5-11-2 빌더 패턴

  • 빌더 패턴으로 객체의 생성과 객체 구현 방식을 분리할 수 있다.

  • Map, Set 등의 자료구조를 사용해봤다면 빌더 패턴에 친숙할 것이다.

    // 빌더 패턴
    new RequestBuilder()
      .setURL('/user')
      .setMethod('get')
      .setData({ firstName: 'Anna' })
      .send();
  • RequestBuilder 구현

    class RequestBuilder {
      private url: string | null = null;
      private method: 'get' | 'post' | null = null;
      private data: object | null = null;
    
      setMethod(method: 'get' | 'post'): this {
        this.method = method;
        return this;
      }
      setData(data: object): this {
        this.data = data;
        return this;
      }
      setURL(url: string): this {
        this.url = url;
        return this;
      }
      send() {
        // ...
      }
    }

    이런 방식의 빌더 설계는 완벽하게 안전하지 않다.
    예를 들어 method, url, data 등을 설정하지 않은 상태에서 .send를 호출할 수 있으므로 런타임 예외가 발생할 수 있다.


5.12 마치며

  • 클래스 선언 방법, 클래스 상속과 인터페이스 구현 방법.
  • 클래스를 인스턴스화할 수 없도록 abstract를 추가하는 방법.
  • 클래스의 필드와 메서드에 static을 추가하고 인스턴스에는 추가하지 않는 방법.
  • private, protect, public 가시성 한정자로 필드와 메서드의 접근을 제어하는 방법.
  • readonly 한정자로 필드에 값을 기록할 수 없게 만드는 법 등.
  • thissuper를 안전하게 사용하는 방법을 알아보고 이들이 클래스 값과 클래스 타입 모두에 어떤 의미인지 확인
  • 타입 별칭과 인터페이스의 차이, 선언 합치기 개념, 클래스에 제네릭 타입을 사용하는 방법 등
  • 믹스인, 데코레이터, final 클래스 흉내내기 등 고급 패턴
  • 유명한 디자인 패턴 2가지