Javascript

TypeScript로 클래스가 아닌 것 상속하기

드리프트2 2024. 11. 9. 12:38

TypeScript로 클래스가 아닌 것 상속하기

기존 클래스 방식

원래 JavaScript에는 클래스가 없었고, 생성자 함수로 클래스를 구현했었죠.

 

현재의 class 문법도 사실은 약간의 차이만 있을 뿐, 옛날 방식의 설탕 문법(syntax sugar)입니다.

 

TypeScript에서도 일부 타입을 조금 속이면 이 방법으로 클래스를 만들 수 있습니다.

const Foo: {
  new (greeting?: string): Foo;
} = function (this: Foo, greeting?: string) {
  this.greeting = greeting || "Hello";
} as any;

Foo.prototype.greet = function () {
  console.log(`${this.greeting}, world!`);
};

interface Foo {
  greeting: string;
  greet(): void;
}

new Foo().greet(); // => Hello, world!

 

이 경우, TypeScript는 Foo가 클래스인지 아닌지 모르고, 단순히 "new 시그니처를 가진 객체"로 인식합니다.

 

기존 클래스의 상속

JavaScript에서는 이런 기존 방식의 클래스를 class 문법으로 상속할 수 있습니다.

 

TypeScript도 여기에 타입을 적용할 수 있습니다.

class Bar extends Foo {
  constructor() {
    // 여기도 타입이 적용됨
    super("Good evening");
  }
}

// 이 호출에도 타입이 적용됨
new Bar().greet(); // => Good evening, world!

생성자 시그니처의 반환 타입 제약

생성자 시그니처의 반환 타입 자체에는 특별한 제약이 없지만, 이상한 타입의 클래스를 상속하려고 하면 문제가 발생합니다. (TypeScript 4.1.2 기준)

interface User {
  type: "user";
  userId: number;
}
interface Admin {
  type: "admin";
  adminId: number;
}
type Entity = User | Admin;

const Entity: new () => Entity = function (): Entity {
  return { type: "user", userId: 1 };
} as any;

// 에러: 기본 생성자의 반환 타입 'Entity'는 객체 타입이거나
// 정적으로 알려진 멤버를 가진 객체 타입의 교차점이어야 합니다. (2509)
class ExtendedEntity extends Entity {}

 

즉, 상속을 시도할 때 생성자 시그니처의 반환 타입이 "interface와 유사한 타입"이어야 한다는 것입니다.

 

또한, 생성자 시그니처가 오버로드된 경우, 모든 타입이 동일해야 합니다.

const WeirdClass: {
  new (isAdmin: false): { type: "user" };
  new (isAdmin: true): { type: "admin" };
} = function (isAdmin: boolean): { type: "user" } | { type: "admin" } {
  if (isAdmin) {
    return { type: "user" };
  } else {
    return { type: "admin" };
  }
} as any;

// 에러: 기본 생성자는 모두 동일한 반환 타입을 가져야 합니다. (2510)
class SubClass extends WeirdClass {}

다형성 생성자

생성자 시그니처도 호출 시그니처처럼 다형성으로 만들 수 있습니다.

 

다형성 생성자 함수를 그대로 상속하려고 하면 타입 에러가 발생합니다.

const IdClass: {
  new<T>(value: T): T;
} = function<T>(value: T): T {
  return value;
} as any;

// 에러: 지정된 수의 타입 인수를 가진 기본 생성자가 없습니다. (2508)
class ExtendedUser extends IdClass {}

 

이런 경우, extends 바로 뒤의 식에 제네릭 인수를 추가하여 타입을 확정해야 합니다.

 

문법적으로는 일반적인 상속, 예를 들어 class MyComponent extends React.Component<Props>와 동일합니다.

const IdClass: {
  new<T>(value: T): T;
} = function<T>(value: T): T {
  return value;
} as any;

class ExtendedUser extends IdClass<{ type: "user" }> {}

 

제네릭 인수의 개수가 다른 생성자 시그니처는 공존 가능하며, extends에 지정한 개수와 일치하는 생성자 시그니처가 선택됩니다.

const WeirdlyOverloadedClass: {
  new (): { greeting: string };
  new <T>(value: T): T;
} = function<T>(value?: T): T | { greeting: string } {
  if (value) return value;
  return { greeting: "Hello" };
} as any;

class ExtendedGreeting extends WeirdlyOverloadedClass {}
class ExtendedUser extends WeirdlyOverloadedClass<{ type: "user" }> {}

 

RecordMap<string, any>의 서브클래스라는 것은 Record의 본질적인 기능인 프로퍼티 접근자의 타입이 전혀 포함되지 않는다는 것을 의미합니다.

 

이는 타입을 적용하는 의미가 반감되므로, Record에 더 적절한 타입을 적용하는 방법을 모색하게 되었습니다.

 

자세한 내용은 생략하지만, Record는 클래스를 생성하는 함수이고, Record(defaultValue)를 상속함으로써 자체 클래스를 정의하는 방식입니다.

 

또한, 동적으로 프로퍼티 접근자가 생성되므로, 일반적인 class 선언으로 Record의 타입을 적용하기 어렵고, 생성자 시그니처를 직접 작성해야 했습니다.