클래스
클래스 (Classes)
ES2015에 도입된 class 키워드는 JavaScript가 객체 지향 프로그래밍을 하는 표준 방식이 됐어요. TypeScript는 그 class를 완전히 지원하면서, 클래스와 다른 타입 사이의 관계를 표현할 수 있는 타입 주석과 문법을 추가해 줍니다. 필드·생성자·메서드부터 상속, 접근 제한자, 추상 클래스까지 이 장에서 클래스의 기능을 하나씩 살펴볼게요.
출처: TypeScript 핸드북
배경 지식: Classes (MDN)
TypeScript는 ES2015에 도입된 class 키워드를 완전히 지원합니다.
다른 JavaScript 언어 기능들과 마찬가지로, TypeScript는 클래스와 다른 타입 사이의 관계를 표현할 수 있도록 타입 주석 및 다른 문법을 추가합니다.
클래스 멤버 (Class Members)
여기 가장 기본적인 클래스가 있습니다 — 빈 클래스죠:
class Point {}
이 클래스는 아직 별로 유용하지 않으니, 멤버를 추가해 보기 시작해 봅시다.
필드 (Fields)
필드 선언은 클래스에 공개(public) 쓰기 가능한 프로퍼티를 만듭니다:
// @strictPropertyInitialization: false
class Point {
x: number;
y: number;
}
const pt = new Point();
pt.x = 0;
pt.y = 0;
다른 위치에서와 마찬가지로 타입 주석은 선택 사항이지만, 지정하지 않으면 암시적 any가 됩니다.
필드는 또한 초기화자 를 가질 수 있습니다. 이들은 클래스가 인스턴스화될 때 자동으로 실행됩니다:
class Point {
x = 0;
y = 0;
}
const pt = new Point();
// Prints 0, 0
console.log(`${pt.x}, ${pt.y}`);
const, let, var에서와 마찬가지로, 클래스 프로퍼티의 초기화자는 그 타입을 추론하는 데 사용됩니다:
// @errors: 2322
class Point {
x = 0;
y = 0;
}
// ---cut---
const pt = new Point();
pt.x = "0";
--strictPropertyInitialization
strictPropertyInitialization 설정은 클래스 필드가 생성자에서 초기화되어야 하는지 제어합니다.
// @errors: 2564
class BadGreeter {
name: string;
}
class GoodGreeter {
name: string;
constructor() {
this.name = "hello";
}
}
필드는 생성자 자체에서 초기화되어야 한다는 점에 주의하세요. TypeScript는 생성자에서 호출하는 메서드를 분석해 초기화를 감지하지 않습니다. 파생 클래스가 그 메서드들을 오버라이드하고 멤버를 초기화하지 못할 수 있기 때문이죠.
생성자가 아닌 다른 수단(예를 들어 외부 라이브러리가 클래스의 일부를 채워준다거나)으로 필드를 확실히 초기화하려 한다면, 확정 할당 단언 연산자(, definite assignment assertion operator), !를 사용할 수 있어요:
class OKGreeter {
// Not initialized, but no error
name!: string;
}
readonly
필드 앞에 readonly 수정자를 붙일 수 있어요. 이것은 생성자 밖에서 필드에 대한 할당을 방지합니다.
// @errors: 2540 2540
class Greeter {
readonly name: string = "world";
constructor(otherName?: string) {
if (otherName !== undefined) {
this.name = otherName;
}
}
err() {
this.name = "not ok";
}
}
const g = new Greeter();
g.name = "also not ok";
생성자 (Constructors)
배경 지식: Constructor (MDN)
클래스 생성자는 함수와 매우 비슷합니다. 타입 주석, 기본값, 오버로드를 가진 매개변수를 추가할 수 있어요:
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
}
}
클래스 생성자 시그니처와 함수 시그니처 사이에는 몇 가지 차이점이 있습니다:
- 생성자는 타입 매개변수를 가질 수 없습니다 — 그것들은 바깥 클래스 선언에 속하며, 나중에 배울 거예요.
- 생성자는 반환 타입 주석을 가질 수 없습니다 — 클래스 인스턴스 타입이 항상 반환되기 때문이죠.
super 호출
JavaScript에서와 마찬가지로, 기본 클래스가 있다면 어떤 this. 멤버를 사용하기 전에 생성자 본문에서 super();를 호출해야 합니다:
// @errors: 17009
class Base {
k = 4;
}
class Derived extends Base {
constructor() {
// Prints a wrong value in ES5; throws exception in ES6
console.log(this.k);
super();
}
}
super를 호출하는 것을 잊는 것은 JavaScript에서 쉽게 저지르는 실수이지만, TypeScript는 그것이 필요할 때 알려 줍니다.
메서드 (Methods)
배경 지식: Method definitions
클래스의 함수 프로퍼티를 메서드 라고 합니다. 메서드는 함수와 생성자가 사용하는 모든 타입 주석을 사용할 수 있어요:
class Point {
x = 10;
y = 10;
scale(n: number): void {
this.x *= n;
this.y *= n;
}
}
표준 타입 주석 외에 TypeScript는 메서드에 새로 추가하는 것이 없습니다.
메서드 본문 안에서는 this.를 통해 필드와 다른 메서드에 접근하는 것이 여전히 필수라는 점에 주의하세요. 메서드 본문의 이름 없는(unqualified) 이름은 항상 둘러싸는 스코프의 무언가를 가리킵니다:
// @errors: 2322
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";
}
}
Getters / Setters
클래스는 접근자(accessors) 도 가질 수 있어요:
class C {
_length = 0;
get length() {
return this._length;
}
set length(value) {
this._length = value;
}
}
추가 논리가 없는 필드 기반의 get/set 쌍은 JavaScript에서 거의 유용하지 않다는 점에 주의하세요. get/set 연산 중 추가 논리를 추가할 필요가 없다면 공개 필드를 노출해도 괜찮아요.
TypeScript에는 접근자에 대한 몇 가지 특별한 추론 규칙이 있습니다:
get은 있지만set이 없으면, 프로퍼티는 자동으로readonly입니다.- setter 매개변수의 타입을 지정하지 않으면 getter의 반환 타입에서 추론됩니다.
TypeScript 4.3부터는 getter와 setter에 서로 다른 타입을 가진 접근자를 가질 수 있습니다.
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)
객체 지향 기능을 가진 다른 언어들처럼, JavaScript의 클래스는 기본 클래스로부터 상속받을 수 있습니다.
implements 절
implements 절을 사용해 클래스가 특정 interface를 충족하는지 검사할 수 있어요. 클래스가 그것을 올바르게 구현하지 못하면 에러가 발행됩니다:
// @errors: 2420
interface Pingable {
ping(): void;
}
class Sonar implements Pingable {
ping() {
console.log("ping!");
}
}
class Ball implements Pingable {
pong() {
console.log("pong!");
}
}
클래스는 여러 인터페이스를 구현할 수도 있습니다, 예를 들면 class C implements A, B {처럼요.
주의사항
implements 절은 클래스가 인터페이스 타입으로 취급될 수 있는지를 검사할 뿐이라는 점을 이해하는 것이 중요합니다. 그것은 클래스나 그 메서드의 타입을 전혀 바꾸지 않습니다. 흔한 실수는 implements 절이 클래스 타입을 바꿀 것이라고 가정하는 것입니다 — 바꾸지 않아요!
// @errors: 7006
interface Checkable {
check(name: string): boolean;
}
class NameChecker implements Checkable {
check(s) {
// Notice no error here
return s.toLowerCase() === "ok";
// ^?
}
}
이 예시에서 우리는 s의 타입이 check의 name: string 매개변수의 영향을 받을 것이라고 기대했을 수도 있어요. 그렇지 않습니다 — implements 절은 클래스 본문이 검사되는 방식이나 그 타입이 추론되는 방식을 바꾸지 않습니다.
이와 유사하게, 선택적 프로퍼티가 있는 인터페이스를 구현한다고 그 프로퍼티가 생성되지는 않습니다:
// @errors: 2339
interface A {
x: number;
y?: number;
}
class C implements A {
x = 0;
}
const c = new C();
c.y = 10;
extends 절
배경 지식: extends keyword (MDN)
클래스는 기본 클래스에서 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);
메서드 오버라이딩
배경 지식: super keyword (MDN)
파생 클래스는 기본 클래스의 필드나 프로퍼티를 오버라이드할 수도 있습니다. super. 문법을 사용해 기본 클래스 메서드에 접근할 수 있어요. JavaScript 클래스는 단순한 조회 객체이므로 "슈퍼 필드(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");
파생 클래스가 기본 클래스의 계약을 따르는 것이 중요합니다. 파생 클래스 인스턴스를 기본 클래스 참조를 통해 참조하는 것은 매우 흔하고(항상 합법적입니다!) 기억하세요:
class Base {
greet() {
console.log("Hello, world!");
}
}
class Derived extends Base {}
const d = new Derived();
// ---cut---
// Alias the derived instance through a base class reference
const b: Base = d;
// No problem
b.greet();
만약 Derived가 Base의 계약을 따르지 않는다면 어떨까요?
// @errors: 2416
class Base {
greet() {
console.log("Hello, world!");
}
}
class Derived extends Base {
// Make this parameter required
greet(name: string) {
console.log(`Hello, ${name.toUpperCase()}`);
}
}
만약 이 코드를 에러에도 불구하고 컴파일한다면, 이 샘플은 그다음에 crash 할 것입니다:
declare class Base {
greet(): void;
}
declare class Derived extends Base {}
// ---cut---
const b: Base = new Derived();
// Crashes because "name" will be undefined
b.greet();
타입 전용 필드 선언 (Type-only Field Declarations)
target >= ES2022이거나 useDefineForClassFields가 true일 때, 클래스 필드는 부모 클래스 생성자가 완료된 후 초기화되어 부모 클래스가 설정한 값을 덮어씁니다. 상속된 필드에 대해 더 정확한 타입을 재선언하고 싶을 때만 이는 문제가 될 수 있어요. 이러한 경우를 다루기 위해 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)
JavaScript 클래스가 초기화되는 순서는 어떤 경우에는 놀라울 수 있어요. 이 코드를 생각해 봅시다:
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();
무슨 일이 벌어졌을까요?
JavaScript가 정의한 클래스 초기화 순서는 다음과 같습니다:
- 기본 클래스 필드가 초기화됩니다.
- 기본 클래스 생성자가 실행됩니다.
- 파생 클래스 필드가 초기화됩니다.
- 파생 클래스 생성자가 실행됩니다.
즉, 기본 클래스 생성자는 파생 클래스 필드 초기화가 아직 실행되지 않았기 때문에, 자신의 생성자 동안 name의 자기 자신의 값을 보았습니다.
내장 타입 상속하기 (Inheriting Built-in Types)
참고:
Array,Error,Map같은 내장 타입에서 상속받을 계획이 없거나, 컴파일 target을 명시적으로ES6/ES2015이상으로 설정했다면 이 섹션을 건너뛰어도 됩니다.
ES2015에서 객체를 반환하는 생성자는 super(...)에 대한 모든 호출자에게 this의 값을 암시적으로 대체합니다. 생성된 생성자 코드가 super(...)의 잠재적 반환 값을 포착해 this로 대체하는 것이 필요합니다.
결과적으로 Error, Array 등을 서브클래싱하는 것이 더 이상 예상대로 동작하지 않을 수 있습니다. Error, Array 등의 생성자 함수가 프로토타입 체인을 조정하기 위해 ECMAScript 6의 new.target을 사용하기 때문이지만, ECMAScript 5에서 생성자를 호출할 때 new.target의 값을 보장할 방법이 없습니다. 다른 다운레벨 컴파일러들은 일반적으로 기본적으로 같은 제한을 갖습니다.
다음과 같은 서브클래스의 경우:
class MsgError extends Error {
constructor(m: string) {
super(m);
}
sayHello() {
return "hello " + this.message;
}
}
다음을 발견할 수도 있습니다:
- 이 서브클래스들을 생성해 얻은 객체에서 메서드가
undefined일 수 있어서,sayHello를 호출하면 에러가 발생합니다. instanceof가 서브클래스의 인스턴스 사이에서 깨져서(new MsgError()) instanceof MsgError가false를 반환합니다.
권장사항으로, 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 이하에서는 동작하지 않습니다. 프로토타입에서 인스턴스 자체로 메서드를 수동으로 복사할 수는 있지만(i.e. MsgError.prototype을 this로), 프로토타입 체인 자체는 고칠 수 없습니다.
멤버 가시성 (Member Visibility)
TypeScript를 사용해 특정 메서드나 프로퍼티가 클래스 밖의 코드에 보이는지 제어할 수 있어요.
public
클래스 멤버의 기본 가시성은 public입니다. public 멤버는 어디서든 접근할 수 있어요:
class Greeter {
public greet() {
console.log("hi!");
}
}
const g = new Greeter();
g.greet();
public이 이미 기본 가시성 수정자이므로 클래스 멤버에 작성할 필요는 절대 없지만, 스타일/가독성상의 이유로 작성하기로 선택할 수는 있어요.
protected
protected 멤버는 그것이 선언된 클래스의 서브클래스에만 보입니다.
// @errors: 2445
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();
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 접근
TypeScript는 클래스 계층에서 형제(sibling) 클래스의 protected 멤버에 접근하는 것을 허용하지 않습니다:
// @errors: 2446
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;
}
}
이것은 Derived2의 x에 대한 접근은 Derived2의 서브클래스에서만 합법이어야 하고, Derived1은 그중 하나가 아니기 때문입니다. 더욱이 Derived1 참조를 통한 x 접근이 불법이라면(확실히 그래야 합니다!), 기본 클래스 참조를 통한 접근이 상황을 개선해서는 안 됩니다.
같은 주제에 대한 C#의 논리를 더 설명하는 Why Can’t I Access A Protected Member From A Derived Class?도 참고하세요.
private
private은 protected와 비슷하지만, 서브클래스에서조차 멤버에 대한 접근을 허용하지 않습니다:
// @errors: 2341
class Base {
private x = 0;
}
const b = new Base();
// Can't access from outside the class
console.log(b.x);
// @errors: 2341
class Base {
private x = 0;
}
// ---cut---
class Derived extends Base {
showX() {
// Can't access in subclasses
console.log(this.x);
}
}
private 멤버는 파생 클래스에 보이지 않으므로, 파생 클래스는 그 가시성을 높일 수 없습니다:
// @errors: 2415
class Base {
private x = 0;
}
class Derived extends Base {
x = 1;
}
인스턴스 간 private 접근
서로 다른 OOP 언어들은 같은 클래스의 서로 다른 인스턴스가 서로의 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;
}
}
주의사항
TypeScript 타입 시스템의 다른 측면들과 마찬가지로, private과 protected는 타입 검사 중에만 강제됩니다.
이것은 in이나 단순 프로퍼티 조회 같은 JavaScript 런타임 구조물이 여전히 private 또는 protected 멤버에 접근할 수 있다는 뜻입니다:
class MySafe {
private secretKey = 12345;
}
// In a JavaScript file...
const s = new MySafe();
// Will print 12345
console.log(s.secretKey);
private은 또한 타입 검사 중에 대괄호 표기법으로 접근하는 것을 허용합니다. 이로 인해 private으로 선언된 필드는 단위 테스트 같은 것에 접근하기가 더 쉬워질 수 있지만, 이러한 필드들은 소프트 private(, soft private)이고 엄격하게 프라이버시를 강제하지 않는 단점이 있습니다.
// @errors: 2341
class MySafe {
private secretKey = 12345;
}
const s = new MySafe();
// Not allowed during type checking
console.log(s.secretKey);
// OK
console.log(s["secretKey"]);
TypeScript의 private과 달리, JavaScript의 private 필드(#)는 컴파일 후에도 private으로 유지되며 대괄호 표기법 접근 같은 앞서 언급한 탈출구를 제공하지 않아 하드 private 을 만듭니다.
class Dog {
#barkAmount = 0;
personality = "happy";
constructor() {}
}
// @target: esnext
// @showEmit
class Dog {
#barkAmount = 0;
personality = "happy";
constructor() {}
}
ES2021 이하로 컴파일할 때 TypeScript는 # 대신 WeakMap을 사용합니다.
// @target: es2015
// @showEmit
class Dog {
#barkAmount = 0;
personality = "happy";
constructor() {}
}
클래스의 값들을 악의적인 사용자로부터 보호해야 한다면, 클로저, WeakMap, 또는 private 필드 같은 하드 런타임 프라이버시를 제공하는 메커니즘을 사용해야 합니다. 이러한 추가된 런타임 프라이버시 검사는 성능에 영향을 줄 수 있다는 점에 주의하세요.
정적 멤버 (Static Members)
배경 지식: Static Members (MDN)
클래스는 static 멤버를 가질 수 있습니다. 이 멤버들은 클래스의 특정 인스턴스와 연관되지 않습니다. 클래스 생성자 객체 자체를 통해 접근할 수 있어요:
class MyClass {
static x = 0;
static printX() {
console.log(MyClass.x);
}
}
console.log(MyClass.x);
MyClass.printX();
정적 멤버는 같은 public, protected, private 가시성 수정자를 사용할 수 있습니다:
// @errors: 2341
class MyClass {
private static x = 0;
}
console.log(MyClass.x);
정적 멤버는 또한 상속됩니다:
class Base {
static getGreeting() {
return "Hello world";
}
}
class Derived extends Base {
myGreeting = Derived.getGreeting();
}
특별한 정적 이름 (Special Static Names)
Function 프로토타입의 프로퍼티를 덮어쓰는 것은 일반적으로 안전하지 않거나 불가능합니다. 클래스 자체가 new로 호출될 수 있는 함수이기 때문에, 특정 static 이름은 사용할 수 없습니다. name, length, call 같은 함수 프로퍼티는 static 멤버로 정의할 수 없습니다:
// @errors: 2699
class S {
static name = "S!";
}
왜 정적 클래스가 없나?
TypeScript(및 JavaScript)는 예를 들어 C#이 하는 것과 같은 의미의 static class라는 구조물을 갖고 있지 않습니다.
그런 구조물들은 그 언어들이 모든 데이터와 함수를 클래스 안에 강제로 넣기 때문에 만 존재합니다. TypeScript에는 그런 제한이 없으므로 그것들이 필요하지 않아요. 단일 인스턴스만 가진 클래스는 JavaScript/TypeScript에서 보통 일반 객체 로 표현됩니다.
예를 들어 TypeScript에는 "static class" 문법이 필요 없습니다. 일반 객체(또는 최상위 함수)는 그 역할을 똑같이 잘 수행하니까요:
// Unnecessary "static" class
class MyStaticClass {
static doSomething() {}
}
// Preferred (alternative 1)
function doSomething() {}
// Preferred (alternative 2)
const MyHelperObject = {
dosomething() {},
};
클래스의 static 블록
정적 블록을 사용하면 자신만의 스코프를 가진 일련의 명령문을 작성할 수 있고, 포함하는 클래스의 private 필드에 접근할 수 있습니다. 즉, 명령문 작성의 모든 능력을 갖춘 초기화 코드를 변수의 누출 없이, 그리고 클래스 내부에 대한 완전한 접근으로 작성할 수 있습니다.
declare function loadLastInstances(): any[]
// ---cut---
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!");
// ^?
클래스는 인터페이스와 같은 방식으로 제네릭 제약과 기본값을 사용할 수 있습니다.
정적 멤버의 타입 매개변수
이 코드는 합법적이지 않으며, 왜 그런지 명확하지 않을 수도 있어요:
// @errors: 2302
class Box<Type> {
static defaultValue: Type;
}
타입은 항상 완전히 지워진다는 것을 기억하세요! 런타임에는 단 하나의 Box.defaultValue 프로퍼티 슬롯이 있습니다. 이는 Box<string>.defaultValue를 설정하는 것이(가능하다면) Box<number>.defaultValue도 또한 바꿀 것임을 의미합니다 — 좋지 않죠. 제네릭 클래스의 static 멤버는 클래스의 타입 매개변수를 절대 참조할 수 없습니다.
런타임에서의 클래스 this
배경 지식: this keyword (MDN)
TypeScript가 JavaScript의 런타임 동작을 바꾸지 않는다는 것, 그리고 JavaScript가 다소 특이한 런타임 동작들로 유명하다는 것을 기억하는 것이 중요합니다.
JavaScript의 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)
배경 지식: Arrow functions (MDN)
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에서 특별한 의미를 갖습니다. 이 매개변수들은 컴파일 중에 지워집니다:
type SomeType = any;
// ---cut---
// TypeScript input with 'this' parameter
function fn(this: SomeType, x: number) {
/* ... */
}
// JavaScript output
function fn(x) {
/* ... */
}
TypeScript는 this 매개변수를 가진 함수가 올바른 컨텍스트로 호출되는지 검사합니다. 화살표 함수를 사용하는 대신, 메서드 정의에 this 매개변수를 추가해 메서드가 올바르게 호출되도록 정적으로 강제할 수 있어요:
// @errors: 2684
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());
이 메서드는 화살표 함수 방식과 반대의 트레이드오프를 만듭니다:
- JavaScript 호출자는 그 사실을 깨닫지 못한 채 클래스 메서드를 잘못 사용할 수도 있습니다.
- 클래스 정의당 하나의 함수만 할당됩니다. 클래스 인스턴스당 하나가 아니라요.
- 기본 메서드 정의는 여전히
super를 통해 호출될 수 있습니다.
this 타입
클래스에서 this라는 특별한 타입은 현재 클래스의 타입을 동적으로 가리킵니다. 이것이 어떻게 유용한지 살펴봅시다:
class Box {
contents: string = "";
set(value: string) {
// ^?
this.contents = value;
return this;
}
}
여기서 TypeScript는 set의 반환 타입을 Box가 아니라 this로 추론했습니다. 이제 Box의 서브클래스를 만들어 봅시다:
class Box {
contents: string = "";
set(value: string) {
this.contents = value;
return this;
}
}
// ---cut---
class ClearableBox extends Box {
clear() {
this.contents = "";
}
}
const a = new ClearableBox();
const b = a.set("hello");
// ^?
매개변수 타입 주석에서도 this를 사용할 수 있어요:
class Box {
content: string = "";
sameAs(other: this) {
return other.content === this.content;
}
}
이것은 other: Box를 쓰는 것과 다릅니다. 파생 클래스가 있다면, 그 sameAs 메서드는 이제 그 같은 파생 클래스의 다른 인스턴스만 받아들입니다:
// @errors: 2345
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);
this 기반 타입 가드
클래스와 인터페이스의 메서드 반환 위치에서 this is Type을 사용할 수 있어요. 타입 좁히기(예: if 문)와 섞일 때 대상 객체의 타입이 지정된 Type으로 좁혀집니다.
// @strictPropertyInitialization: false
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;
// ^?
} else if (fso.isDirectory()) {
fso.children;
// ^?
} else if (fso.isNetworked()) {
fso.host;
// ^?
}
this 기반 타입 가드의 일반적인 사용 사례는 특정 필드의 지연 검증(lazy validation)을 허용하는 것입니다. 예를 들어, 이 경우는 hasValue가 true로 검증되었을 때 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;
// ^?
if (box.hasValue()) {
box.value;
// ^?
}
매개변수 프로퍼티 (Parameter Properties)
TypeScript는 생성자 매개변수를 같은 이름과 값을 가진 클래스 프로퍼티로 바꾸는 특별한 문법을 제공합니다. 이것들을 매개변수 프로퍼티 라고 하며, 생성자 인자에 public, private, protected, readonly 중 하나의 가시성 수정자를 접두사로 붙여 만듭니다. 결과 필드는 그 수정자(들)를 얻습니다:
// @errors: 2341
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);
// ^?
console.log(a.z);
클래스 표현식 (Class Expressions)
클래스 표현식은 클래스 선언과 매우 유사합니다. 유일한 실제 차이점은 클래스 표현식은 이름이 필요 없다는 것이지만, 그것들이 최종적으로 바인딩된 어떤 식별자를 통해 참조할 수는 있어요:
const someClass = class<Type> {
content: Type;
constructor(value: Type) {
this.content = value;
}
};
const m = new someClass("Hello, world");
// ^?
생성자 시그니처 (Constructor Signatures)
JavaScript 클래스는 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 일 수 있어요.
추상 메서드 또는 추상 필드 는 구현이 제공되지 않은 것입니다. 이 멤버들은 직접적으로 인스턴스화할 수 없는 추상 클래스 안에 존재해야 합니다.
추상 클래스의 역할은 추상 멤버를 모두 구현하는 서브클래스의 기본 클래스로 제공되는 것입니다. 클래스에 추상 멤버가 없으면 구상(concrete) 이라고 합니다.
예시를 살펴봅시다:
// @errors: 2511
abstract class Base {
abstract getName(): string;
printName() {
console.log("Hello, " + this.getName());
}
}
const b = new Base();
Base는 추상이므로 new로 인스턴스화할 수 없습니다. 대신 파생 클래스를 만들고 추상 멤버를 구현해야 합니다:
abstract class Base {
abstract getName(): string;
printName() {}
}
// ---cut---
class Derived extends Base {
getName() {
return "world";
}
}
const d = new Derived();
d.printName();
기본 클래스의 추상 멤버를 구현하는 것을 잊으면 에러를 받는다는 점에 주의하세요:
// @errors: 2515
abstract class Base {
abstract getName(): string;
printName() {}
}
// ---cut---
class Derived extends Base {
// forgot to do anything
}
추상 생성 시그니처 (Abstract Construct Signatures)
가끔은 어떤 추상 클래스에서 파생된 클래스의 인스턴스를 만드는 어떤 클래스 생성자 함수를 받아들이고 싶을 때가 있어요.
예를 들어 이 코드를 작성하고 싶을 수 있습니다:
// @errors: 2511
abstract class Base {
abstract getName(): string;
printName() {}
}
class Derived extends Base {
getName() {
return "";
}
}
// ---cut---
function greet(ctor: typeof Base) {
const instance = new ctor();
instance.printName();
}
TypeScript는 당신이 추상 클래스를 인스턴스화하려고 한다고 올바르게 알려 주고 있습니다. 어쨌든 greet의 정의를 감안하면, 결국 추상 클래스를 만드는 이 코드를 작성하는 것은 완전히 합법적이니까요:
declare const greet: any, Base: any;
// ---cut---
// Bad!
greet(Base);
대신, 생성 시그니처(construct signature)를 가진 무언가를 받아들이는 함수를 작성하고 싶을 거예요:
// @errors: 2345
abstract class Base {
abstract getName(): string;
printName() {}
}
class Derived extends Base {
getName() {
return "";
}
}
// ---cut---
function greet(ctor: new () => Base) {
const instance = new ctor();
instance.printName();
}
greet(Derived);
greet(Base);
이제 TypeScript는 어떤 클래스 생성자 함수가 호출될 수 있는지 올바르게 알려 줍니다 — Derived는 구상이므로 가능하고, Base는 불가능합니다.
클래스 간의 관계 (Relationships Between Classes)
대부분의 경우 TypeScript의 클래스는 다른 타입들과 마찬가지로 구조적으로 비교됩니다.
예를 들어, 이 두 클래스는 동일하므로 서로를 대신해 사용할 수 있습니다:
class Point1 {
x = 0;
y = 0;
}
class Point2 {
x = 0;
y = 0;
}
// OK
const p: Point1 = new Point2();
마찬가지로, 명시적 상속이 없어도 클래스 사이에 하위 타입 관계가 존재합니다:
// @strict: false
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);