체스 엔진을 만드는 예를 들어보겠습니다.
먼저 가장 기본적인 타입부터 정의하여야 합니다.
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 {}
체스 말에는 색깔로 상대를 구분하고 체스판의 좌표는 x축이 왼쪽에서 오른쪽으로 A부터 H까지이고, y축이 아래에서 위로 1부터 8까지입니다.
이것을 타입으로 정의하면,
type Color = 'Black' | 'White'
type File = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H'
type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
위와 같이 정의할 수 있습니다.
타입으로 올 수 있는 값이 많지 않을 때는 이처럼 직접 열거할 수 있습니다.
위의 타입들은 모두 같은 타입의 값들이 열거되어 있으므로 타입 안전성 또한 확보할 수 있습니다.
이제 정의한 타입을 바탕으로 클래스를 정의해보겠습니다.
class Piece {
protected position: Position
constructor(
private readonly color:Color,
file:File,
rank:Rank
){
this.position = new Position(file, rank)
}
}
class Position {
constructor(
private file: File,
private rank: Rank
){}
}
Piece와 Position은 위처럼 정의할 수 있을 것입니다.
따라서 여기서 file은 this.file이, rank는 this.rank가 됩니다.
Position 인스턴스 안의 코드는 이 매개변수를 읽고 쓸 수 있지만 Position 인스턴스 외부에서는 접근할 수 없습니다. 하지만 Position 인스턴스끼리는 다른 인스턴스의 private 멤버에도 접근할 수 있습니다.
protected도 private처럼 프로퍼티를 this에 할당하지만 private과 달리 인스턴스와 서브클래스의 인스턴스 모두에 접근을 허용합니다. Piece에서 position을 선언하면서 할당은 하지 않았으므로 생성자에서는 값을 할당하거나 그렇지 않다면 position의 타입을 Position | undefined로 바꾸어야 합니다.
만약, 사용자가 Piece 인스턴스를 직접 생성하지 못하게 막고 대신 Queen이나 Bishop 등 Piece 클래스를 상속받은 클래스를 통해서만 인스턴스화할 수 있도록 허용하고 싶다면 abstract 키워드를 사용하면 됩니다.
abstract class Piece {
protected position: Position
constructor(
private readonly color:Color,
file:File,
rank:Rank
){
this.position = new Position(file, rank)
}
abstract canMoveTo(position: Position): boolean
}
이렇게 정의하면 Piece를 직접 인스턴스화하려고 시도하면 타입스크립트 (이하 TS)가 에러를 발생시킵니다.
class Position {
constructor(
private file: File,
private rank: Rank
){}
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 && distance.file < 2
}
}
Position 클래스와 King 클래스에 메서드를 추가하고
class Game {
private pieces = Game.makePieces()
private static makePieces() {
return {
new King("White", "E", 1),
new King("Black", "E", 8),
new Queen("White", "D", 1),
new Queen("Black", "D", 9),
...
}
}
}
새 게임을 만들 때 자동으로 보드와 말을 만들도록 코드를 추가합니다.
일일이 모든 메서드를 구현하지는 않았지만 TS에서 클래스가 어떻게 동작하는지 감을 잡을 수 있을 것입니다.
요약하자면 다음과 같습니다.
자식 클래스가 부모 클래스에 정의된 메서드를 오버라이드 하면 자식 인스턴스는 super를 이용하여 부모 버전의 메서드를 호출할 수 있습니다.
자식 클래스에 생성자 함수가 있다면 super()를 호출해야 부모 클래스와 정상적으로 연결됩니다.
super로 부모 클래스의 메서드에만 접근할 수 있고 프로퍼티에는 접근할 수 없습니다.
this는 값뿐만 아니라 타입으로도 사용할 수 있습니다. 클래스를 정의할 때라면 메서드의 반환 타입을 지정할 때 this 타입을 활용할 수 있습니다.
class Set {
has(value: number): boolean {
}
add(value: number): this {
}
}
이렇게 메서드의 반환 타입을 this로 지정하면 만약 다른 클래스가 Set을 상속한 경우에도 Set의 모든 메서드를 오버라이딩할 필요가 없어집니다.
타입 별칭과 인터페이스의 차이점부터 정리하고 넘어가겠습니다.
타입 별칭과 인터페이스는 문법만 다를 뿐 거의 같은 기능을 수행합니다.
type Sushi = {
calories: number
salty: boolean
tasty: boolean
}
interface Sushi {
calories: number
salty: boolean
tasty: boolean
}
Sushi 타입 별칭을 사용한 모든 곳에 Sushi 인터페이스를 대신 사용할 수 있습니다.
type Food = {
calories: number
tasty: boolean
}
type Sushi = Food & {
salty: boolean
}
인터페이스의 특징은 다음과 같습니다.
interface User {
name: string
}
interface User {
age: number
}
const a: User = {
name: "jimmy",
age: 27
}
위에서 User 인터페이스를 따로 정의하였다면 결과적으로 합쳐진 상태가 되어 2개의 User 인터페이스의 멤버를 사용할 수 있습니다.
이를 '선언 합침'이라고 하며 인터페이스끼리 충돌하면 에러가 발생합니다.
타입 별칭에서는 불가능한 기능입니다.
클래스를 선언할 때는 implements 키워드를 통해 인터페이스와의 구현 관계를 선언할 수 있습니다.
interface Animal {
eat(food: string): void
sleep(hours: number): void
}
class Human implements Animal {
eat(food: string) {
...
}
sleep(hours: number) {
con
}
}
여기서 Human은 Animal이 선언하는 모든 메서드를 구현해야 하며, 필요하다면 메서드나 프로퍼티를 추가로 구현할 수 있습니다.
프로퍼티를 선언할 때에 private, protected, public, 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 Act {
talk(): void
}
class Human implements Animal, Act {
eat(food: string) {
...
}
sleep(hours: number) {
con
}
talk() {
...
}
}
이 모든 기능은 완전한 타입 안정성을 제공합니다. 프로퍼티를 놓치거나 구현에 문제가 있으면 TS가 바로 에러를 발생시킵니다.
인터페이스 구현은 추상 클래스 상속과 매우 비슷합니다. 그러나 인터페이스가 더 범용으로 쓰이며 가벼운 반면, 추상 클래스는 특별한 목적과 풍부한 기능을 갖습니다.
여러 클래스에서 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 '이 클래스는 T다'라고 말하는 것이 목적이라면 인터페이스를 사용하는 것이 좋습니다.
클래스는 값이 될 수도 있고 타입이 될 수도 있습니다.
class C {}
let c:C = new C;
enum E {F, G}
let e:E = E.F;
클래스뿐만 아니라 열거형 (enum)도 마찬가지입니다.
클래스와 인터페이스에도 제네릭을 사용할 수 있습니다.
제네릭 타입의 범위는 클래스나 인터페이스 전체가 되게 할 수도 있고 특정 메서드로 한정할 수도 있습니다.
class MyMap<K, V> {
constructor(key: K, value: V) {
...
}
get(key: K): V {
,,,
}
set(key: K, value: V): void {
...
}
static of<K, V>(key: K, value: V): MyMap<K, V> {
...
}
}
TS에서는 private constructor로 final 클래스를 흉내 낼 수 있습니다.
생성자를 private으로 선언하면 new로 인스턴스를 생성하거나 클래스를 확장할 수 없게 됩니다.
만약 상속만 막고 인스턴스화는 정상적으로 하게 하려면
class Message {
private constructor(private messages: string[]) {
}
static create(messages: string[]) {
return new Message(messages);
}
}
위와 같이 static 메서드로 인스턴스를 반환하게 하면 됩니다.