클래스

클래스 (Classes)

클래스는 객체 지향 프로그래밍의 핵심이에요. TypeScript에서는 ES2015에서 도입된 class 키워드를 완전히 지원해서, 다른 자바스크립트 기능과 마찬가지로 타입 어노테이션과 추가 문법으로 클래스와 다른 타입 사이의 관계를 표현하게 해 줍니다.

출처: TypeScript 공식문서

본문

TypeScript의 클래스는 ES2015에서 나온 class 키워드를 완전히 지원해요. 그리고 자바스크립트의 다른 기능들과 마찬가지로, 타입 어노테이션과 추가 문법을 더해서 클래스와 다른 타입 사이의 관계를 표현할 수 있게 해 줍니다.

클래스 멤버 (Class Members)

가장 기본적인 클래스, 그러니까 아무것도 없는 빈 클래스부터 볼게요.

class Point {}

아직 이 클래스는 쓸모가 없죠. 그럼 멤버를 하나씩 추가해 볼게요.

필드 (Fields)

필드 선언은 클래스에 public이며 쓰기 가능한(writeable) 프로퍼티를 만들어 줍니다.

class Point {
  x: number;
  y: number;
}

const pt = new Point();
pt.x = 0;
pt.y = 0;

다른 곳과 마찬가지로 타입 어노테이션은 생략할 수 있어요. 다만 생략하면 암시적으로 any가 된다는 점만 기억해 두세요.

필드에는 *초기화식(initializer)*을 붙일 수도 있는데, 이건 클래스가 인스턴스화될 때 자동으로 실행돼요.

class Point {
  x = 0;
  y = 0;
}

const pt = new Point();
// Prints 0, 0
console.log(`${pt.x}, ${pt.y}`);

const, let, var에서와 똑같이, 클래스 프로퍼티의 초기화식도 타입을 추론하는 데 사용돼요. 아래처럼 pt.x = "0"을 쓰면 타입이 맞지 않는다는 오류가 나요.

const pt = new Point();
pt.x = "0";
// Type 'string' is not assignable to type 'number'. (2322)
--strictPropertyInitialization

strictPropertyInitialization 설정은 클래스 필드를 생성자에서 초기화해야 하는지 여부를 제어해요. 이 설정이 켜져 있으면 아래처럼 초기화하지 않은 필드가 있으면 오류가 나요.

class BadGreeter {
  name: string;
  // Property 'name' has no initializer and is not definitely assigned in the constructor. (2564)
}

반면 생성자에서 초기화해 주면 오류가 사라져요.

class GoodGreeter {
  name: string;

  constructor() {
    this.name = "hello";
  }
}

여기서 주의할 점은, 필드는 생성자 자신 안에서 초기화해야 한다는 거예요. TypeScript는 생성자에서 호출하는 메서드들을 분석해서 초기화를 판단하지 않아요. 왜냐하면 파생 클래스가 그 메서드를 오버라이드해서 멤버 초기화를 실패할 수도 있기 때문이죠.

생성자가 아닌 다른 방법으로 필드를 확실히 초기화하려면(예를 들어 외부 라이브러리가 클래스의 일부를 채워 줄 때), *확정 할당 단언 연산자(definite assignment assertion operator)*인 !를 쓰면 돼요.

class OKGreeter {
  // Not initialized, but no error
  name!: string;
}

readonly

필드 앞에 readonly 수정자를 붙이면 생성자 밖에서의 할당을 막을 수 있어요.

class Greeter {
  readonly name: string = "world";

  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }

  err() {
    this.name = "not ok";
    // Cannot assign to 'name' because it is a read-only property. (2540)
  }
}
const g = new Greeter();
g.name = "also not ok";
// Cannot assign to 'name' because it is a read-only property. (2540)

생성자 안에서는 조건부로라도 할당이 가능하지만, 그 밖에서는 전부 오류가 나는 걸 확인할 수 있어요.

생성자 (Constructors)

클래스 생성자는 함수와 아주 비슷해요. 타입 어노테이션이 있는 파라미터, 기본값, 오버로드를 모두 쓸 수 있습니다.

class Point {
  x: number;
  y: number;

  // Normal signature with defaults
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
class Point {
  x: number = 0;
  y: number = 0;

  // Constructor overloads
  constructor(x: number, y: number);
  constructor(xy: string);
  constructor(x: string | number, y: number = 0) {
    // Code logic here
  }
}

클래스 생성자 시그니처와 함수 시그니처는 몇 가지만 다릅니다.

  • 생성자는 타입 파라미터(type parameters)를 가질 수 없어요. 타입 파라미터는 바깥 클래스 선언에 붙는 건데, 이건 나중에 배울 거예요.
  • 생성자는 반환 타입 어노테이션을 가질 수 없어요. 반환되는 건 항상 클래스 인스턴스 타입이기 때문이에요.
Super 호출 (Super Calls)

자바스크립트에서와 똑같이, 베이스 클래스가 있으면 생성자 본문에서 this. 멤버를 쓰기 전에 super();를 호출해야 해요.

class Base {
  k = 4;
}

class Derived extends Base {
  constructor() {
    // Prints a wrong value in ES5; throws exception in ES6
    console.log(this.k);
    // 'super' must be called before accessing 'this' in the constructor of a derived class. (17009)
    super();
  }
}

자바스크립트에서는 super를 호출하는 걸 잊기 쉬운 실수인데, TypeScript가 필요한 시점에 알려 주니까 크게 걱정하지 않아도 돼요.

메서드 (Methods)

클래스에 있는 함수 프로퍼티를 *메서드(method)*라고 불러요. 메서드는 함수와 생성자에서 쓰는 모든 타입 어노테이션을 그대로 사용할 수 있습니다.

class Point {
  x = 10;
  y = 10;

  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

표준 타입 어노테이션 외에 TypeScript가 메서드에 새로 추가하는 것은 없어요.

다만 메서드 본문 안에서는 필드와 다른 메서드에 접근할 때 반드시 this.를 써야 한다는 점을 기억하세요. 이름만 쓰면 그건 항상 바깥 스코프의 무언가를 가리키게 됩니다.

let x: number = 0;

class C {
  x: string = "hello";

  m() {
    // This is trying to modify 'x' from line 1, not the class property
    x = "world";
    // Type 'string' is not assignable to type 'number'. (2322)
  }
}

Getters / Setters

클래스는 *접근자(accessor)*도 가질 수 있어요.

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

참고로, 추가 로직 없이 필드를 그대로 주고받는 get/set 쌍은 자바스크립트에서 거의 쓸모가 없어요. get/set 과정에 추가 로직이 필요 없다면 그냥 public 필드를 노출하는 편이 낫습니다.

TypeScript는 접근자에 몇 가지 특별한 추론 규칙을 적용해요.

  • get만 있고 set이 없으면 프로퍼티는 자동으로 readonly가 돼요.
  • setter 파라미터의 타입을 지정하지 않으면 getter의 반환 타입에서 추론돼요.
  • TypeScript 4.3부터는 get과 set의 타입이 서로 다른 접근자도 만들 수 있어요.
class Thing {
  _size = 0;

  get size(): number {
    return this._size;
  }

  set size(value: string | number | boolean) {
    let num = Number(value);

    // Don't allow NaN, Infinity, etc
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }

    this._size = num;
  }
}

인덱스 시그니처 (Index Signatures)

클래스도 인덱스 시그니처를 선언할 수 있어요. 동작 방식은 다른 객체 타입의 인덱스 시그니처와 동일합니다.

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);

  check(s: string) {
    return this[s] as boolean;
  }
}

다만 인덱스 시그니처 타입이 메서드의 타입까지 포함해야 하다 보니, 이런 타입을 실용적으로 쓰기는 쉽지 않아요. 일반적으로는 인덱스 데이터를 클래스 인스턴스 자체에 두기보다 다른 곳에 저장하는 편이 좋습니다.

클래스 상속 (Class Heritage)

자바스크립트의 클래스는 다른 객체 지향 언어와 마찬가지로 베이스 클래스로부터 상속받을 수 있어요.

implements

implements 절을 쓰면 클래스가 특정 interface를 만족하는지 검사할 수 있어요. 제대로 구현하지 못하면 오류가 발생합니다.

interface Pingable {
  ping(): void;
}

class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}

class Ball implements Pingable {
  // Class 'Ball' incorrectly implements interface 'Pingable'.
  //   Property 'ping' is missing in type 'Ball' but required in type 'Pingable'. (2420)
  pong() {
    console.log("pong!");
  }
}

클래스는 여러 인터페이스를 구현할 수도 있어요. 예를 들어 class C implements A, B {처럼 쓰면 됩니다.

주의사항 (Cautions)

implements 절은 클래스가 그 인터페이스 타입으로 취급될 수 있는지만 검사하는 장치라는 점을 꼭 이해해야 해요. 클래스 자체의 타입이나 메서드를 전혀 바꾸지 않습니다. implements가 클래스 타입을 바꾼다고 오해하는 건 흔한 실수인데, 실제로는 그러지 않아요!

interface Checkable {
  check(name: string): boolean;
}

class NameChecker implements Checkable {
  check(s) {
    // Parameter 's' implicitly has an 'any' type. (7006)
    // Notice no error here
    return s.toLowerCase() === "ok";
  }
}

이 예시에서 우리는 아마 checkname: string 파라미터가 s의 타입에 영향을 줄 거라 기대했을 거예요. 하지만 그렇지 않아요. implements 절은 클래스 본문이 어떻게 검사되는지, 타입이 어떻게 추론되는지를 바꾸지 않습니다.

마찬가지로, 선택적 프로퍼티를 가진 인터페이스를 구현한다고 해서 그 프로퍼티가 생기는 건 아니에요.

interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;
// Property 'y' does not exist on type 'C'. (2339)

extends

클래스는 베이스 클래스를 extend할 수 있어요. 파생 클래스는 베이스 클래스의 모든 프로퍼티와 메서드를 갖고, 추가 멤버도 정의할 수 있습니다.

class Animal {
  move() {
    console.log("Moving along!");
  }
}

class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log("woof!");
    }
  }
}

const d = new Dog();
// Base class method
d.move();
// Derived class method
d.woof(3);
메서드 오버라이딩 (Overriding Methods)

파생 클래스는 베이스 클래스의 필드나 프로퍼티를 오버라이드할 수도 있어요. 베이스 클래스 메서드에 접근할 때는 super. 문법을 사용합니다. 한 가지 짚고 갈 점은, 자바스크립트 클래스는 단순한 조회(lookup) 객체라서 "슈퍼 필드(super field)"라는 개념은 존재하지 않는다는 거예요.

TypeScript는 파생 클래스가 항상 베이스 클래스의 서브타입이 되도록 강제합니다.

예를 들어, 아래는 메서드를 오버라이드하는 올바른 방법이에요.

class Base {
  greet() {
    console.log("Hello, world!");
  }
}

class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }
}

const d = new Derived();
d.greet();
d.greet("reader");

파생 클래스가 베이스 클래스의 계약(contract)을 따라야 한다는 건 정말 중요해요. 파생 클래스 인스턴스를 베이스 클래스 참조를 통해 다루는 건 아주 흔하고 언제나 합법적인 일이거든요.

// Alias the derived instance through a base class reference
const b: Base = d;
// No problem
b.greet();

그럼 DerivedBase의 계약을 따르지 않으면 어떻게 될까요?

class Base {
  greet() {
    console.log("Hello, world!");
  }
}

class Derived extends Base {
  // Make this parameter required
  greet(name: string) {
    // Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'.
    //   Type '(name: string) => void' is not assignable to type '() => void'.
    //     Target signature provides too few arguments. Expected 1 or more, but got 0. (2416)
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}

만약 오류가 있는데도 이 코드를 컴파일한다면, 실제로 프로그램이 크래시하게 됩니다.

const b: Base = new Derived();
// Crashes because "name" will be undefined
b.greet();
타입 전용 필드 선언 (Type-only Field Declarations)

target >= ES2022이거나 useDefineForClassFieldstrue일 때는, 클래스 필드가 부모 클래스 생성자가 완료된 후에 초기화되면서 부모가 설정한 값을 덮어써요. 상속받은 필드에 더 정확한 타입만 다시 선언하고 싶을 때 이게 문제가 될 수 있어요. 이런 경우 declare를 써서 TypeScript에게 "이 필드 선언은 런타임 효과가 없다"고 알려 줄 수 있습니다.

interface Animal {
  dateOfBirth: any;
}

interface Dog extends Animal {
  breed: any;
}

class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}

class DogHouse extends AnimalHouse {
  // Does not emit JavaScript code,
  // only ensures the types are correct
  declare resident: Dog;
  constructor(dog: Dog) {
    super(dog);
  }
}
초기화 순서 (Initialization Order)

자바스크립트 클래스가 초기화되는 순서는 어떤 경우엔 생각보다 놀라울 수 있어요. 아래 코드를 볼게요.

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}

class Derived extends Base {
  name = "derived";
}

// Prints "base", not "derived"
const d = new Derived();

여기서 무슨 일이 벌어진 걸까요? 자바스크립트가 정의한 클래스 초기화 순서는 이래요.

  • 베이스 클래스 필드가 초기화된다.
  • 베이스 클래스 생성자가 실행된다.
  • 파생 클래스 필드가 초기화된다.
  • 파생 클래스 생성자가 실행된다.

즉, 베이스 클래스 생성자가 실행되는 시점엔 파생 클래스의 필드 초기화가 아직 일어나지 않았기 때문에, 베이스 생성자는 name에 대해 자기 자신의 값을 보게 되는 거예요.

내장 타입 상속 (Inheriting Built-in Types)

참고: Array, Error, Map 같은 내장 타입을 상속할 계획이 없거나, 컴파일 타겟이 명시적으로 ES6/ES2015 이상이라면 이 섹션은 건너뛰어도 돼요.

ES2015에서 객체를 반환하는 생성자는 super(...)를 호출한 쪽에 this의 값을 암시적으로 대체해요. 그래서 생성된 코드는 super(...)의 반환값을 잡아서 this로 바꿔치기할 필요가 있습니다.

그 결과, Error, Array 등을 서브클래싱하면 예상대로 동작하지 않을 수 있어요. 그 이유는 Error, Array 같은 생성자 함수가 ECMAScript 6의 new.target을 사용해서 프로토타입 체인을 조정하는데, ECMAScript 5에서는 생성자를 호출할 때 new.target 값을 보장할 방법이 없기 때문이에요. 다른 다운레벨(downlevel) 컴파일러들도 기본적으로 비슷한 한계를 갖고 있습니다.

예를 들어 이런 서브클래스가 있다면,

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return "hello " + this.message;
  }
}

다음과 같은 문제가 생길 수 있어요.

  • 이 서브클래스로 만든 객체에서 메서드가 undefined일 수 있어서, sayHello를 호출하면 오류가 나요.
  • 서브클래스 인스턴스 사이의 instanceof가 깨져서, (new MsgError()) instanceof MsgErrorfalse를 반환해요.

권장하는 방법은 super(...) 호출 직후에 프로토타입을 수동으로 조정하는 거예요.

class MsgError extends Error {
  constructor(m: string) {
    super(m);

    // Set the prototype explicitly.
    Object.setPrototypeOf(this, MsgError.prototype);
  }

  sayHello() {
    return "hello " + this.message;
  }
}

다만 MsgError의 어떤 서브클래스든 마찬가지로 프로토타입을 수동으로 설정해 줘야 해요. Object.setPrototypeOf를 지원하지 않는 런타임이라면 __proto__를 쓸 수도 있습니다.

아쉽게도 이런 우회 방법은 Internet Explorer 10 이하에서는 동작하지 않아요. 프로토타입에서 인스턴스 자신으로 메서드를 직접 복사할 순 있지만(즉 MsgError.prototypethis로), 프로토타입 체인 자체를 고칠 수는 없습니다.

멤버 가시성 (Member Visibility)

TypeScript를 쓰면 특정 메서드나 프로퍼티가 클래스 바깥의 코드에 보일지 제어할 수 있어요.

public

클래스 멤버의 기본 가시성은 public이에요. public 멤버는 어디에서든 접근할 수 있습니다.

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

public은 이미 기본 가시성 수정자라서 클래스 멤버에 꼭 써 줄 필요는 없지만, 스타일이나 가독성 때문에 쓰기도 해요.

protected

protected 멤버는 선언된 클래스의 서브클래스에서만 볼 수 있어요.

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}

class SpecialGreeter extends Greeter {
  public howdy() {
    // OK to access protected member here
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName();
// Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses. (2445)
protected 멤버의 노출 (Exposure of protected members)

파생 클래스는 베이스 클래스의 계약을 따라야 하지만, 더 많은 기능을 가진 베이스 클래스의 서브타입을 노출할 수도 있어요. protected 멤버를 public으로 만드는 것도 그중 하나입니다.

class Base {
  protected m = 10;
}
class Derived extends Base {
  // No modifier, so default is 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

참고로 Derived는 이미 m을 자유롭게 읽고 쓸 수 있었기 때문에, 이건 상황의 "보안"을 의미 있게 바꾸는 게 아니에요. 여기서 정말 주목할 점은, 파생 클래스에서 이런 노출이 의도된 게 아니라면 protected 수정자를 다시 반복해 줘야 한다는 거예요.

계층 간 protected 접근 (Cross-hierarchy protected access)

TypeScript는 클래스 계층 구조에서 형제(sibling) 클래스의 protected 멤버에 접근하는 것을 허용하지 않아요.

class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Derived1) {
    other.x = 10;
    // Property 'x' is protected and only accessible within class 'Derived1' and its subclasses. (2445)
  }
}

이건 Derived2 안에서 x에 접근하는 것은 Derived2의 서브클래스에서만 합법이어야 하는데, Derived1은 그중 하나가 아니기 때문이에요. 게다가 Derived1 참조를 통한 x 접근이 불법이라면, 베이스 클래스 참조를 통한 접근도 당연히 상황을 개선해 주지 않아야 하죠.

참고로 Why Can’t I Access A Protected Member From A Derived Class? 글은 C#의 같은 주제에 대한 더 자세한 이유를 설명해 줍니다.

private

privateprotected와 비슷하지만, 서브클래스에서조차 멤버에 접근하는 것을 허용하지 않아요.

class Base {
  private x = 0;
}
const b = new Base();
// Can't access from outside the class
console.log(b.x);
// Property 'x' is private and only accessible within class 'Base'. (2341)
class Derived extends Base {
  showX() {
    // Can't access in subclasses
    console.log(this.x);
    // Property 'x' is private and only accessible within class 'Base'. (2341)
  }
}

private 멤버는 파생 클래스에 보이지 않기 때문에, 파생 클래스가 그 가시성을 높이는 것도 불가능해요.

class Base {
  private x = 0;
}
class Derived extends Base {
  // Class 'Derived' incorrectly extends base class 'Base'.
  //   Property 'x' is private in type 'Base' but not in type 'Derived'. (2415)
  x = 1;
}
인스턴스 간 private 접근 (Cross-instance private access)

같은 클래스의 서로 다른 인스턴스가 서로의 private 멤버에 접근할 수 있는지는 객체 지향 언어마다 의견이 갈려요. Java, C#, C++, Swift, PHP는 허용하지만 Ruby는 그렇지 않습니다.

TypeScript는 인스턴스 간 private 접근을 허용해요.

class A {
  private x = 10;

  public sameAs(other: A) {
    // No error
    return other.x === this.x;
  }
}
주의사항 (Caveats)

TypeScript 타입 시스템의 다른 측면들과 마찬가지로, privateprotected타입 검사 중에만 강제됩니다.

즉, in 같은 자바스크립트 런타임 구조나 단순한 프로퍼티 조회는 여전히 private이나 protected 멤버에 접근할 수 있다는 뜻이에요.

class MySafe {
  private secretKey = 12345;
}
// In a JavaScript file...
const s = new MySafe();
// Will print 12345
console.log(s.secretKey);

private은 타입 검사 중에 대괄호 표기법(bracket notation)으로 접근하는 것도 허용해요. 그래서 private으로 선언된 필드는 유닛 테스트 같은 데서 접근하기 쉬워질 수 있는데, 그 대가로 이 필드들은 *소프트 private(soft private)*이라서 프라이버시를 엄격히 강제하지 않는다는 단점이 있습니다.

class MySafe {
  private secretKey = 12345;
}

const s = new MySafe();

// Not allowed during type checking
console.log(s.secretKey);
// Property 'secretKey' is private and only accessible within class 'MySafe'. (2341)

// OK
console.log(s["secretKey"]);

TypeScript의 private과 달리, 자바스크립트의 프라이빗 필드(#)는 컴파일 후에도 프라이빗 상태로 남고, 대괄호 표기법 접근 같은 앞서 말한 탈출구를 제공하지 않아서 *하드 private(hard private)*이에요.

class Dog {
  #barkAmount = 0;
  personality = "happy";

  constructor() {}
}
"use strict";
class Dog {
    #barkAmount = 0;
    personality = "happy";
    constructor() { }
}

ES2021 이하로 컴파일하면 TypeScript는 # 대신 WeakMap을 사용해요.

"use strict";
var _Dog_barkAmount;
class Dog {
    constructor() {
        _Dog_barkAmount.set(this, 0);
        this.personality = "happy";
    }
}
_Dog_barkAmount = new WeakMap();

클래스의 값을 악의적인 공격자로부터 보호해야 한다면, 클로저, WeakMap, 프라이빗 필드 같은 하드 런타임 프라이버시를 제공하는 메커니즘을 쓰는 게 좋아요. 이런 런타임 프라이버시 검사가 성능에 영향을 줄 수 있다는 점도 알아 두세요.

정적 멤버 (Static Members)

클래스는 static 멤버를 가질 수 있어요. 이 멤버들은 특정 인스턴스에 연결되지 않고, 클래스 생성자 객체 자체를 통해 접근할 수 있습니다.

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x);
MyClass.printX();

정적 멤버도 public, protected, private 가시성 수정자를 그대로 사용할 수 있습니다.

class MyClass {
  private static x = 0;
}
console.log(MyClass.x);
// Property 'x' is private and only accessible within class 'MyClass'. (2341)

정적 멤버도 상속됩니다.

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

특별한 정적 이름 (Special Static Names)

Function 프로토타입의 프로퍼티를 덮어쓰는 건 일반적으로 안전하지 않거나 불가능해요. 클래스는 new로 호출할 수 있는 함수 그 자체이기 때문에, 어떤 static 이름은 사용할 수 없습니다. name, length, call 같은 Function 프로퍼티는 static 멤버로 정의하기에 유효하지 않아요.

class S {
  static name = "S!";
  // Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'. (2699)
}

왜 정적 클래스(Static Classes)가 없을까?

TypeScript(그리고 자바스크립트)에는 C# 같은 언어의 static class라는 구성이 없어요.

그런 구성은 그 언어들이 모든 데이터와 함수를 클래스 안에 넣도록 강제하기 때문에 존재하는 거예요. TypeScript에는 그런 제약이 없으니 필요도 없습니다. 인스턴스가 하나뿐인 클래스는 보통 자바스크립트/TypeScript에서 그냥 일반 객체로 표현하곤 해요.

예를 들어 TypeScript에는 "static class" 문법이 필요 없어요. 일반 객체(또는 최상위 함수)가 그 역할을 톡톡히 해내거든요.

// Unnecessary "static" class
class MyStaticClass {
  static doSomething() {}
}

// Preferred (alternative 1)
function doSomething() {}

// Preferred (alternative 2)
const MyHelperObject = {
  dosomething() {},
};

클래스의 static 블록 (static Blocks in Classes)

정적 블록(static blocks)은 자기만의 스코프를 가지면서, 포함하는 클래스의 프라이빗 필드에 접근할 수 있는 일련의 문장들을 쓸 수 있게 해 줘요. 이 말은, 문장을 쓰는 모든 능력을 갖춘 초기화 코드를 변수 유출 없이, 클래스 내부에 완전히 접근하면서 작성할 수 있다는 뜻입니다.

class Foo {
    static #count = 0;

    get count() {
        return Foo.#count;
    }

    static {
        try {
            const lastInstances = loadLastInstances();
            Foo.#count += lastInstances.length;
        }
        catch {}
    }
}

제네릭 클래스 (Generic Classes)

클래스는 인터페이스와 마찬가지로 제네릭일 수 있어요. 제네릭 클래스를 new로 인스턴스화할 때, 타입 파라미터는 함수 호출에서처럼 같은 방식으로 추론됩니다.

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}

const b = new Box("hello!");
// const b: Box<string>

클래스는 인터페이스와 동일한 방식으로 제네릭 제약 조건(constraints)과 기본값(defaults)을 사용할 수 있어요.

정적 멤버의 타입 파라미터 (Type Parameters in Static Members)

아래 코드는 허용되지 않는데, 왜 그런지가 바로 명확히 보이지 않을 수 있어요.

class Box<Type> {
  static defaultValue: Type;
  // Static members cannot reference class type parameters. (2302)
}

타입은 항상 완전히 지워진다는 점을 기억하세요! 런타임에는 Box.defaultValue라는 프로퍼티 슬롯이 하나만 있어요. 즉 만약 Box<string>.defaultValue를 설정하는 게 가능하다면, 그건 Box<number>.defaultValue까지 바꿔 버리는 셈이라 좋지 않아요. 제네릭 클래스의 static 멤버는 절대 클래스의 타입 파라미터를 참조할 수 없습니다.

클래스에서 런타임의 this

TypeScript는 자바스크립트의 런타임 동작을 바꾸지 않는다는 점을 꼭 기억하세요. 그리고 자바스크립트는 유별난 런타임 동작으로 꽤 유명하기도 하죠.

자바스크립트의 this 처리 방식은 확실히 특이합니다.

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};

// Prints "obj", not "MyClass"
console.log(obj.getName());

간단히 말하면, 기본적으로 함수 안의 this 값은 함수가 어떻게 호출되었는지에 따라 달라져요. 이 예시에서는 obj 참조를 통해 함수를 호출했기 때문에 this의 값이 클래스 인스턴스가 아니라 obj가 된 거예요.

이건 우리가 원하는 동작이 아닐 때가 거의 대부분이죠! TypeScript는 이런 종류의 오류를 완화하거나 방지하는 몇 가지 방법을 제공합니다.

화살표 함수 (Arrow Functions)

this 컨텍스트를 잃는 방식으로 자주 호출될 함수가 있다면, 메서드 정의 대신 화살표 함수 프로퍼티를 쓰는 게 합리적일 수 있어요.

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// Prints "MyClass" instead of crashing
console.log(g());

다만 여기엔 트레이드오프가 있어요.

  • this 값은 TypeScript로 검사되지 않는 코드에서도 런타임에 정확하다는 게 보장돼요.
  • 이렇게 정의한 함수는 각 클래스 인스턴스가 자기만의 복사본을 갖기 때문에 메모리를 더 사용해요.
  • 프로토타입 체인에 베이스 클래스 메서드를 가져올 항목이 없기 때문에, 파생 클래스에서 super.getName을 쓸 수 없어요.

this 파라미터

메서드나 함수 정의에서 이름이 this인 첫 번째 파라미터는 TypeScript에서 특별한 의미를 가져요. 이 파라미터는 컴파일 중에 지워집니다.

// TypeScript input with 'this' parameter
function fn(this: SomeType, x: number) {
  /* ... */
}
// JavaScript output
function fn(x) {
  /* ... */
}

TypeScript는 this 파라미터가 있는 함수를 올바른 컨텍스트로 호출하는지 검사해요. 화살표 함수 대신 메서드 정의에 this 파라미터를 추가하면, 메서드가 올바르게 호출되는지 정적으로 강제할 수 있습니다.

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();

// Error, would crash
const g = c.getName;
console.log(g());
// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'. (2684)

이 방법은 화살표 함수 방식과 정반대의 트레이드오프를 가집니다.

  • 자바스크립트 호출자들은 여전히 클래스 메서드를 잘못 사용할 수 있어요(인지하지 못한 채).
  • 클래스 정의마다 함수가 하나만 할당되니, 인스턴스마다 하나씩 생기지 않아요.
  • 베이스 메서드 정의는 여전히 super로 호출할 수 있어요.

this 타입 (this Types)

클래스에서는 this라는 특별한 타입이 동적으로 현재 클래스의 타입을 가리켜요. 이게 얼마나 유용한지 볼게요.

class Box {
  contents: string = "";
  set(value: string) {
    // (method) Box.set(value: string): this
    this.contents = value;
    return this;
  }
}

여기서 TypeScript는 set의 반환 타입을 Box가 아니라 this로 추론했어요. 이제 Box의 서브클래스를 만들어 볼게요.

class ClearableBox extends Box {
  clear() {
    this.contents = "";
  }
}

const a = new ClearableBox();
const b = a.set("hello");
// const b: ClearableBox

this를 파라미터 타입 어노테이션으로도 쓸 수 있어요.

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

이건 other: Box라고 쓰는 것과는 달라요. 파생 클래스가 있으면, 그 sameAs 메서드는 같은 파생 클래스의 다른 인스턴스만 받게 됩니다.

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

class DerivedBox extends Box {
  otherContent: string = "?";
}

const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
// Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
//   Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'. (2345)

this 기반 타입 가드 (this-based type guards)

클래스와 인터페이스의 메서드 반환 위치에 this is Type을 쓸 수 있어요. 타입 내로잉(예: if 문)과 함께 쓰면 대상 객체의 타입이 지정한 Type으로 좁혀집니다.

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}

class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}

class Directory extends FileSystemObject {
  children: FileSystemObject[];
}

interface Networked {
  host: string;
}

const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");

if (fso.isFile()) {
  fso.content;
  // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children;
  // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host;
  // const fso: Networked & FileSystemObject
}

this 기반 타입 가드의 흔한 용도는 특정 필드의 지연 검증(lazy validation)을 가능하게 하는 거예요. 예를 들어 아래 경우는 hasValue가 참임을 확인했을 때 box 안에 담긴 값에서 undefined를 제거해 줍니다.

class Box<T> {
  value?: T;

  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}

const box = new Box<string>();
box.value = "Gameboy";

box.value;
// (property) Box<string>.value?: string

if (box.hasValue()) {
  box.value;
  // (property) value: string
}

파라미터 프로퍼티 (Parameter Properties)

TypeScript는 생성자 파라미터를 같은 이름과 값의 클래스 프로퍼티로 바꿔 주는 특별한 문법을 제공해요. 이를 *파라미터 프로퍼티(parameter properties)*라고 부르고, 생성자 인자 앞에 public, private, protected, readonly 같은 가시성 수정자 중 하나를 붙여서 만듭니다. 결과 필드는 그 수정자(들)를 갖게 돼요.

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // No body necessary
  }
}
const a = new Params(1, 2, 3);
console.log(a.x);
// (property) Params.x: number
console.log(a.z);
// Property 'z' is private and only accessible within class 'Params'. (2341)

클래스 표현식 (Class Expressions)

클래스 표현식은 클래스 선언과 아주 비슷해요. 진짜 차이는 클래스 표현식은 이름이 필요 없다는 것 하나뿐인데, 그래도 클래스 표현식에 묶인 식별자를 통해 참조할 수는 있습니다.

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};

const m = new someClass("Hello, world");
// const m: someClass<string>

생성자 시그니처 (Constructor Signatures)

자바스크립트 클래스는 new 연산자로 인스턴스화돼요. 클래스 자체의 타입이 주어졌을 때, InstanceType 유틸리티 타입이 이 연산을 모델링합니다.

class Point {
  createdAt: number;
  x: number;
  y: number
  constructor(x: number, y: number) {
    this.createdAt = Date.now()
    this.x = x;
    this.y = y;
  }
}
type PointInstance = InstanceType<typeof Point>

function moveRight(point: PointInstance) {
  point.x += 5;
}

const point = new Point(3, 4);
moveRight(point);
point.x; // => 8

abstract 클래스와 멤버

TypeScript의 클래스, 메서드, 필드는 abstract일 수 있어요.

*추상 메서드(abstract method)*나 *추상 필드(abstract field)*는 구현이 제공되지 않은 것을 말해요. 이런 멤버들은 반드시 추상 클래스(abstract class) 안에 존재해야 하며, 추상 클래스는 직접 인스턴스화할 수 없습니다.

추상 클래스의 역할은 모든 추상 멤버를 구현하는 서브클래스들의 베이스 클래스가 되어 주는 거예요. 추상 멤버가 하나도 없는 클래스를 구상(concrete) 클래스라고 부릅니다.

예시를 볼게요.

abstract class Base {
  abstract getName(): string;

  printName() {
    console.log("Hello, " + this.getName());
  }
}

const b = new Base();
// Cannot create an instance of an abstract class. (2511)

Base는 추상이라서 new로 인스턴스화할 수 없어요. 대신 파생 클래스를 만들고 추상 멤버를 구현해야 합니다.

class Derived extends Base {
  getName() {
    return "world";
  }
}

const d = new Derived();
d.printName();

베이스 클래스의 추상 멤버 구현을 잊으면 오류가 난다는 점도 확인할 수 있어요.

class Derived extends Base {
  // Non-abstract class 'Derived' does not implement inherited abstract member getName from class 'Base'. (2515)
  // forgot to do anything
}

추상 생성 시그니처 (Abstract Construct Signatures)

때로는 어떤 추상 클래스에서 파생된 클래스의 인스턴스를 만들어 내는 클래스 생성자 함수를 받고 싶을 때가 있어요.

예를 들어 이런 코드를 쓰고 싶을 수 있습니다.

function greet(ctor: typeof Base) {
  const instance = new ctor();
  // Cannot create an instance of an abstract class. (2511)
  instance.printName();
}

TypeScript는 추상 클래스를 인스턴스화하려 한다고 정확히 알려 주고 있어요. 어차피 greet의 정의만 보면, 결국 추상 클래스를 만들게 되는 이 코드를 쓰는 것도 전적으로 합법적이거든요.

대신 생성 시그니처(construct signature)를 가진 무언가를 받는 함수를 쓰는 게 맞아요.

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
// Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
//   Cannot assign an abstract constructor type to a non-abstract constructor type. (2345)

이제 TypeScript가 어떤 클래스 생성자 함수를 호출할 수 있는지 정확히 알려 줍니다. Derived는 구상이라 가능하지만, Base는 그렇지 않아요.

클래스 간의 관계 (Relationships Between Classes)

대부분의 경우 TypeScript의 클래스는 다른 타입들과 마찬가지로 구조적으로(structurally) 비교돼요.

예를 들어 아래 두 클래스는 동일해서 서로를 대신해 사용할 수 있습니다.

class Point1 {
  x = 0;
  y = 0;
}

class Point2 {
  x = 0;
  y = 0;
}

// OK
const p: Point1 = new Point2();

마찬가지로, 명시적인 상속이 없더라도 클래스 사이에 서브타입 관계가 존재할 수 있어요.

class Person {
  name: string;
  age: number;
}

class Employee {
  name: string;
  age: number;
  salary: number;
}

// OK
const p: Person = new Employee();

직관적으로 들리지만, 다른 경우보다 더 이상해 보이는 몇 가지 경우가 있어요.

빈 클래스는 멤버가 없어요. 구조적 타입 시스템에서 멤버가 없는 타입은 일반적으로 다른 무엇이든 슈퍼타입이 됩니다. 그래서 빈 클래스를 만들면(하지 마세요!) 아무거나 그 자리에 쓸 수 있게 돼요.

class Empty {}

function fn(x: Empty) {
  // can't do anything with 'x', so I won't
}

// All OK!
fn(window);
fn({});
fn(fn);

더 알아보기