개요
- TS 클래스를 컴파일하면 일반 자바스크립트 클래스가 되므로 믹스인(mixin) 같은 자바스크립트 표현식도 타입 안정성을 유지하며 사용할 수 있다.
- 프로퍼티 초기자와 데코레이터 같은 TS 기능 일부는 자바스크립트에서도 지원하므로 실제 런타임 코드를 생성한다.
- 반면 가시성 접근자, 인터페이스, 제네릭 등은 TS만의 고유 기능이므로 컴파일 타임에만 존재한다.
체스 엔진 만들기
-
두 명이 체스를 둘 수 있는 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
에 할당하며(즉,file
은this.file
이 된다) 가시성은private
으로 설정한다. - 즉,
Position
인스턴스 안의 코드는 이 매개변수를 읽고 쓸 수 있지만 Position 인스턴스 외부에서는 접근할 수 없다. Position
인스턴스끼리는 다른 인스턴스의private
멤버에 접근할 수 있다.(다른 클래스의 인스턴스는 심지어Position
의 서브클래스조차도 비공개 멤버에 접근할 수 없다.)
- 생성자의
-
- 인스턴스 변수
position
은protected
로 선언했다. protected
도private
처럼 프로퍼티를this
에 할당하지만private
과 달리Piece
의 인스턴스와Piece
의 서브클래스 인스턴스 모두에 접근을 허용한다.position
을 선언하면서 할당은 하지 않았으므로Piece
의 생성자 함수에서 값을 할당해야 한다.- 생성자에서도 값을 할당하지 않으면 TS는 변수가 명확하게 할당되지 않았다고 불평한다.
- 이는 변수의 타입을
T
라고 선언했지만 실제로는 프로퍼티 초기자나 생성자에서 값을 할당하지 않아T | undefined
타입으로 결정되므로position
의 타입을Position
또는undefined
가 될 수 있도록 시그니처를 바꿔야 한다.
- 인스턴스 변수
-
new Piece
는color
,file
,rank
세 개의 매개변수를 받는다.color
에 두 가지 한정자를 추가했는데private
은color
를this
로 할당해서Piece
의 인스턴스에서만 이 변수에 접근할 수 있게 만들며,readonly
는 초기에 값을 할당한 다음에는 더 이상 값을 덮어쓸 수 없게 한다.
-
접근 한정자를 이용해 내부 구현 정보를 너무 많이 공개하지 않고 잘 정의된 API만 노출하도록 클래스를 설계할 수 있다.
-
여기서는 사용자가
Piece
인스턴스를 직접 생성하지 못하게 막고 대신Queen
이나Bishop
등Piece
클래스를 상속받은 클래스를 통해서만 인스턴스화할 수 있도록 허용할 것이다.
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
)
-
canMoveTo
를King
클래스를 구현해 넣어보자.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, ... ] } }
-
Rank
와File_
의 타입을 엄격하게 설정했으므로J
같은 미리 지정되지 않은 문자나12
같이 범위를 벗어나는 숫자를 입력하면 에러를 발생시킨다. -
요약하자면 다음과 같다.
class
키워드로 클래스를 선언한 후extends
키워드로 다른 클래스를 상속받을 수 있다.- 클래스는 구체 클래스와 추상 클래스로 분류된다. 추상 클래스는 추상 메서드와 추상 프로퍼티를 가질 수 있다.
- 메서드는 3가지 한정자(
private
,protected
,public
(default))중 한 가지 한정자를 가질 수 있다. 메서드는 인스턴스 메서드와 정적 메서드 두 가지로 구분된다. - 인스턴스 프로퍼티를 선언할 때
readonly
를 추가할 수 있다.
- 자식 클래스가 부모 클래스에 정의된 메서드를 오버라이드하면(예:
Queen
,Piece
둘 다take
메서드를 구현하는 상황) 자식 인스턴스는super
를 이용해 부모 버전의 메서드를 호출할 수 있다.(예:super.take
) - TS는 두 가지
super
호출을 지원한다.super.take
같은 메서드 호출- 생성자 함수에서만 호출할 수 있는
super()
라는 특별한 타입의 생성자 호출.- 자식 클래스에 생성자 함수가 있다면
super()
를 호출해야 부모 클래스와 정상적으로 연결된다.
- 자식 클래스에 생성자 함수가 있다면
this
를 타입으로도 사용할 수 있다.- 클래스에 정의할 때라면 메서드의 반환 타입을 지정할 때
this
타입을 유용하게 활용할 수 있다. - ES6 Set 자료구조를 두 가지 연산만 지원하도록 간단하게 구현해보자.
class Set { has(value: number): boolean { // ... } add(value: number): Set { // add를 호출하면 Set 인스턴스 반환 // ... } }
- 이번엔
Set
을 상속받는 서브클래스를 만들어보자. Set
의add
메서드는 여전히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 인스턴스 반환 // ... } }
- 이제 서브클래스에서도 서브클래스의 인스턴스를 가리키므로 오버라이드 할 필요가 없다.
-
클래스는 인터페이스를 통해 사용할 때가 많다.
-
인터페이스를 사용하면 타입을 더 깔금하게 정의할 수 있다.
-
타입 별칭과 인터페이스는 문법만 다를 뿐 거의 같은 기능을 수행한다.(마치 함수 표현식과 함수 구현의 차이와 비슷)
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; };
인터페이스가 반드시 다른 인터페이스를 상속받아야 하는 것은 아니다.
사실 인터페이스는 객체 타입, 클래스, 다른 인터페이스 모두를 상속받을 수 있다.-
타입 별칭은 더 일반적이어서 타입 별칭의 오른편에는 타입 표현식(타입 그리고
&
,|
등의 타입 연산자)을 포함한 모든 타입이 등장할 수 있다. 반면 인스턴스의 오른편에는 반드시 형태가 나와야한다. 예를 들어 다음 타입 별칭 코드는 인터페이스로 다시 작성할 수 없다.type A = number; type B = A | string;
-
인터페이스를 상속할 때 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
를 오버로드한 시그니처가 만들어진다.
- 인터섹션 타입을 사용하면 상황이 달라진다. 위의 예에서 인터페이스는 타입별칭으로 바꾸고
-
이름과 범위가 같은 인터페이스가 여러 개 있다면 이들이 자동으로 합쳐진다.(타입 별칭이 여러개라면 컴파일 타임 에러가난다.) 이를 선언 합침이라 부른다.
-
- 선언 합침(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; }
- 이 경우 두 타입이 서로 동일할 뿐 아니라 할당할 수 있는지까지 확인하는 보기 드문 상황.
- 클래스를 선언할 때
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!');
}
}
-
Cat
은Animal
이 선언하는 모든 메서드를 구현해야 하며, 필요하다면 메서드나 프로퍼티를 추가로 구현할 수 있다. -
인터페이스로 인스턴스 프로퍼티를 정의할 수 있지만 가시성 한정자는 선언할 수 없으며
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'); } }
-
이 모든 기능은 완전한 타입 안전성을 제공한다.
- 둘은 아주 비슷하지만, 인터페이스가 더 범용으로 쓰이며 가벼운 반면, 추상 클래스는 특별한 목적과 풍부한 기능을 갖는다는 점이 다르다.
- 인터페이스
- 인터페이스는 형태를 정의하는 수단이다.
- 인터페이스는 아무런 자바스크립트 코드를 만들지 않으며 컴파일 타임에만 존재한다.
- 추상 클래스
- 추상 클래스는 오직 클래스만 정의할 수 있다.
- 추상 클래스는 런타임의 자바스크립트 클래스 코드를 만든다.
- 인터페이스에서 제공되지 않는 기능을 지원한다.(생성자, 기본 구현, 접근 한정자)
- 인터페이스
- 여러 클래스에서 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 형태를 정의하는 것이 목적이라면 인터페이스를 사용하자.
-
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이 아님
-
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
이라는 한 개의 메서드를 포함하며new
는StringDatabase
인스턴스를 반환한다. 두 인터페이스를 합치면StringDatabase
클래스 생성자와 인스턴스가 완성된다. -
new()
코드를 생성자 시그니처라 부르며, 이는new
연산자로 해당 타입을 인스턴스화할 수 있음을 정의하는 타입스크립트의 방식이다. -
앞서 봤듯 TS는 구조를 기반으로 타입을 구분하기 때문에 이 방식이 클래스가 무엇인지를 기술하는 최선이다.
-
앞의 예는 인수를 전혀 받지 않는 생성자이지만 인수를 받는 생성자도 선언할 수 있다.
-
예를 들어 선택적으로 초기 상태를 받도록 수정한 모습이다.
class StringDatabase { constructor(public state: State = {}) {} // ... } interface StringDatabaseConstructor { new (state?: state): StringDatabase; from(state: state): StringDatabase; }
-
클래스 정의는 용어는 값 수준과 타입 수준으로 생성할 뿐 아니라, 타입 수준에서는 두 개의 용어를 생성했다. 하나는 클래스의 인스턴스를 가리키며, 다른 하나는 클래스 생성자 자체를 가리킨다.
-
함수와 타입처럼, 클래스와 인터페이스도 기본값과 상한/하한 설정을 포함한 다양한 제네릭 타입 매개변수 기능을 지원한다.
-
제네릭 타입의 범위는 클래스나 인터페이스 전체가 되게 할 수도 있고 특정 메서드로 한정할 수도 있다.
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 } }
class
와 제네릭을 선언했으므로 클래스 전체에서 타입을 사용할 수 있다.MyMap
의 모든 인스턴스 메서드와 인스턴스 프로퍼티에서K
와V
를 사용할 수 있다.constructor
에는 제네릭 타입을 선언할 수 없음을 기억하자.constructor
대신class
선언에 사용해야 한다.- 클래스로 한정된 제네릭 타입은 클래스 내부의 어디에서나 사용할 수 있다.
- 인스턴스 메서드는 클래스 수준 제네릭을 사용할 수 있으며 자신만의 제네릭도 추가로 선언할 수 있다.
- 정적 메서드는 클래스의 인스턴스 변수에 값 수준에서 접근할 수 없듯이 클래스 수준의 제네릭을 사용할 수 없다. 따라서
of
는 1에서 선언한K
와V
에 접근할 수 없고 자신만의K
와V
를 직접 선언했다.
-
인터페이스에서도 제네릭을 사용할 수 있다.
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);
-
자바스크립트나 타입스크립트는
trait
나mixin
키워드를 제공하지 않지만 손쉽게 직접 구현할 수 있다. -
두 키워드 모두 둘 이상의 클래스를 상속받는 다중 상속(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 } }; }
new
로 만들 수 있는 모든 것을 생성자라고 규정한다. 또한 생성자에 어떤 타입의 매개변수가 올지 알 수 없으므로 임의의 개수의any
타입 인수를 받을 수 있게 지정했다.- 한 개의 타입 매개변수
C
만 받도록withEZDebug
믹스인을 선언했다.extends
로 최소한 클래스 생성자여야하도록 강제하고,withEZDebug
의 반환 타입은C
와 새로운 익명 클래스의 교집합이며 TS를 이를 추론하도록 했다. - 믹스인은 생정자를 인수로 받아 생성자를 반환하는 함수이므로 익명 클래스 생성자를 반환했다.
- 이 생성자는 최소한 우리가 전달한 클래스가 받는 인수를 받을 수 있어야 한다. 하지만 어떤 클래스를 전달할 지 모름으로 임의의 개수
any
타입을 받도록 구현했다. - 마지막으로 이 익명 클래스는 다른 클래스를 상속받으므로
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})'로 평가
-
필요한 수의 믹스인을 클래스에 제공함으로 더 풍부한 동작을 제공할 수 있으며 타입 안정성도 보장된다.
-
믹스인은 동작을 캡슐화할 뿐 아니라 동작을 재사용할 수 있도록 도와준다.
-
데코레이터(decorator)는 TS의 실험적인 기능으로 클래스, 클래스 메서드, 프로퍼티, 메서드 매개변수를 활용한 메타 프로그래밍에 깔끔한 문법을 제공한다.
-
데코레이터는 장식하는 대상의 함수를 호출하는 기능을 제공하는 문법이다.
TSC 플래그: experimentalDecorators
데코레이터는 실험 단계의 기능으로 해당 플래그를 따로 설정해야 사용할 수 있다.
@serializable class APIPayload { getValue(): Payload { // ... } }
-
클래스 데코레이터인
@serializable
은APIPayload
클래스를 감싸고 있으며 선택적으로 이를 대체하는 새 클래스를 반환한다. -
데코레이터를 사용하지 않고 같은 기능을 구현할 수도 있다.
let APIPayload = serializable(class APIPayload) { getValue(): Payload{ // ... } }
-
TS는 데코레이터 타입 각각에 대해 주어진 이름 범위에 존재하는 함수와 해당 데코레이터 타입에 요구되는 시그니처를 필요로 한다. (p. 128, 표 5-1)
-
TS가 기본으로 제공하는 데코레이터는 없다. 직접 구현하거나 NPM으로 설치해야 한다.
-
데코레이터 기능이 더 완벽해지기 전까지는 일반 함수를 사용할 것을 권한다.
-
자세한 정보는 공식 문서를 확인하자.
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); } }
-
팩토리 패턴은 어떤 객체를 만들지 전적으로 팩토리에 위임한다.
-
신발 팩토리를 만들어보자. 먼저
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(); } }, };
type
을 유니온 타입으로 지정해서.create
의 타입 안정성을 최대한 강화했다.switch
문을 이용해 누락된Shoe
타입이 없는지 TS가 쉽게 확인할 수 있게 한다.
-
컴패니언 객체 패턴("6-3-4 컴패니언 객체 패턴" 참고)으로 타입
Shoe
와 값Shoe
를 같은 이름으로 선언했다.(TS는 값과 네임스페이스를 따로 관리) -
이 팩토리를 이용하려면 그저
.create
를 호출하면 된다.Shoe.create('boot'); // Shoe
-
이 코드를 발전시켜
'boot'
를 전달하면Boot
을 반환함을 드러내개끔Shoe.create
의 타입 시그니처를 수정할 수 있다. 하지만 그러면 팩토리 패턴이 제공하는 추상화 규칙을 깨는 결과를 초래한다.
(호출자는 팩토리가 특정 인터페이스를 만족하는 클래스를 제공할 것이라는 사실만 알 뿐 어떤 구체 클래스가 이 일을 하는지 알 수 없어야 한다.)
-
빌더 패턴으로 객체의 생성과 객체 구현 방식을 분리할 수 있다.
-
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를 호출할 수 있으므로 런타임 예외가 발생할 수 있다.
- 클래스 선언 방법, 클래스 상속과 인터페이스 구현 방법.
- 클래스를 인스턴스화할 수 없도록
abstract
를 추가하는 방법. - 클래스의 필드와 메서드에
static
을 추가하고 인스턴스에는 추가하지 않는 방법. private
,protect
,public
가시성 한정자로 필드와 메서드의 접근을 제어하는 방법.readonly
한정자로 필드에 값을 기록할 수 없게 만드는 법 등.this
와super
를 안전하게 사용하는 방법을 알아보고 이들이 클래스 값과 클래스 타입 모두에 어떤 의미인지 확인- 타입 별칭과 인터페이스의 차이, 선언 합치기 개념, 클래스에 제네릭 타입을 사용하는 방법 등
- 믹스인, 데코레이터, final 클래스 흉내내기 등 고급 패턴
- 유명한 디자인 패턴 2가지