선언 병합

선언 병합 (Declaration Merging)

TypeScript에는 JavaScript 객체의 형태를 타입 레벨에서 표현하는 독특한 개념들이 몇 가지 있어요. 그중에서도 특히 TypeScript만의 개념이 바로 선언 병합(declaration merging) 입니다. 이 개념을 이해하면 기존 JavaScript 코드를 다룰 때 한 수 앞서 나갈 수 있고, 더 고급스러운 추상화 개념으로 가는 문도 열려요.

출처: TypeScript 핸드북

도입부

이 문서에서 말하는 "선언 병합"은 컴파일러가 같은 이름으로 선언된 두 개의 별도 선언을 하나의 정의로 병합하는 것을 뜻합니다. 이렇게 병합된 정의는 두 원래 선언의 특징을 모두 갖게 되죠. 병합은 두 개로 한정되지 않고, 몇 개든 병합할 수 있습니다.

기본 개념 (Basic Concepts)

TypeScript에서 선언은 namespace, type, value라는 세 그룹 중 적어도 하나에 엔티티를 만듭니다. namespace를 만드는 선언은 점 표기법(dotted notation)으로 접근하는 이름들을 담은 namespace를 만들고, 타입을 만드는 선언은 선언된 형태로 보이고 주어진 이름에 묶인 타입을 만들며, 값을 만드는 선언은 출력 JavaScript에 보이는 값을 만듭니다.

선언 타입 (Declaration Type) Namespace Type Value
Namespace X X
Class X X
Enum X X
Interface X
Type Alias X
Function X
Variable X

각 선언이 무엇을 만드는지 이해하면, 선언 병합을 수행할 때 무엇이 병합되는지도 자연스럽게 이해할 수 있어요.

인터페이스 병합 (Merging Interfaces)

가장 단순하고, 아마 가장 흔한 선언 병합은 인터페이스 병합입니다. 가장 기본적인 수준에서 병합은 두 선언의 멤버를 같은 이름의 하나의 인터페이스로 기계적으로 합칩니다.

interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
}

let box: Box = { height: 5, width: 6, scale: 10 };

인터페이스의 비-함수 멤버는 유일해야 합니다. 유일하지 않다면 같은 타입이어야 하죠. 두 인터페이스가 같은 이름의 비-함수 멤버를 선언했는데 타입이 다르면 컴파일러가 오류를 냅니다.

함수 멤버의 경우, 같은 이름의 각 함수 멤버는 같은 함수의 오버로드를 설명하는 것으로 취급됩니다. 주의할 점은, 나중에 병합되는 인터페이스 A가 앞선 인터페이스 A보다 우선순위가 높다는 것입니다.

즉 이 예시에서:

interface Cloner {
  clone(animal: Animal): Animal;
}

interface Cloner {
  clone(animal: Sheep): Sheep;
}

interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
}

세 인터페이스는 병합되어 다음과 같은 하나의 선언이 됩니다.

interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
  clone(animal: Sheep): Sheep;
  clone(animal: Animal): Animal;
}

각 그룹의 요소들은 순서를 유지하지만, 그룹 자체는 나중의 오버로드 집합이 먼저 오도록 병합된다는 점을 눈치채셨나요?

이 규칙의 예외가 하나 있는데, 바로 **특수화된 시그니처(specialized signature)**입니다. 시그니처에 단일 문자열 리터럴 타입(예: 문자열 리터럴의 유니언이 아닌)인 파라미터가 있으면, 그것은 병합된 오버로드 목록의 맨 위로 올라갑니다.

예를 들어 다음 인터페이스들은 병합됩니다.

interface Document {
  createElement(tagName: any): Element;
}
interface Document {
  createElement(tagName: "div"): HTMLDivElement;
  createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
  createElement(tagName: string): HTMLElement;
  createElement(tagName: "canvas"): HTMLCanvasElement;
}

병합된 Document 선언의 결과는 다음과 같습니다.

interface Document {
  createElement(tagName: "canvas"): HTMLCanvasElement;
  createElement(tagName: "div"): HTMLDivElement;
  createElement(tagName: "span"): HTMLSpanElement;
  createElement(tagName: string): HTMLElement;
  createElement(tagName: any): Element;
}

네임스페이스 병합 (Merging Namespaces)

같은 이름의 네임스페이스도 인터페이스와 비슷하게 멤버를 병합합니다. 네임스페이스는 namespace와 value를 둘 다 만들기 때문에, 둘이 어떻게 병합되는지 이해해야 해요.

namespace를 병합할 때는 각 네임스페이스에서 export된 인터페이스의 타입 정의가 서로 병합되어, 병합된 인터페이스 정의를 안에 담은 하나의 namespace를 이룹니다.

namespace 을 병합할 때는, 각 선언 지점에서 이미 같은 이름의 namespace가 존재한다면, 그 기존 namespace를 가져와 두 번째 namespace의 export 멤버들을 첫 번째에 추가하는 방식으로 더 확장됩니다.

이 예시의 Animals 선언 병합은:

namespace Animals {
  export class Zebra {}
}

namespace Animals {
  export interface Legged {
    numberOfLegs: number;
  }
  export class Dog {}
}

다음과 동일합니다.

namespace Animals {
  export interface Legged {
    numberOfLegs: number;
  }

  export class Zebra {}
  export class Dog {}
}

이 namespace 병합 모델은 유용한 출발점이지만, export되지 않은 멤버에서는 어떤 일이 벌어지는지도 이해해야 합니다. export되지 않은 멤버는 원래의 (병합되지 않은) namespace에서만 보입니다. 즉 병합된 후에는, 다른 선언에서 온 병합 멤버들이 export되지 않은 멤버를 볼 수 없습니다.

이 예시에서 더 명확히 볼 수 있어요.

namespace Animal {
  let haveMuscles = true;

  export function animalsHaveMuscles() {
    return haveMuscles;
  }
}

namespace Animal {
  export function doAnimalsHaveMuscles() {
    return haveMuscles; // Error, because haveMuscles is not accessible here
  }
}

haveMuscles가 export되지 않았기 때문에, 같은 (병합되지 않은) namespace를 공유하는 animalsHaveMuscles 함수만 이 심볼을 볼 수 있습니다. 병합된 Animal namespace의 일부인 doAnimalsHaveMuscles 함수는 이 미export 멤버를 볼 수 없죠.

클래스, 함수, 열거형과의 네임스페이스 병합

네임스페이스는 다른 종류의 선언과도 병합될 만큼 유연합니다. 그러려면 namespace 선언이 병합 대상 선언을 따라서 와야 하고, 결과 선언은 두 선언 타입의 속성을 모두 갖습니다. TypeScript는 이 능력을 활용해 JavaScript와 다른 프로그래밍 언어의 일부 패턴을 모델링합니다.

클래스와 네임스페이스 병합

이건 사용자에게 내부 클래스(inner class) 를 표현하는 방법을 줍니다.

class Album {
  label: Album.AlbumLabel;
}
namespace Album {
  export class AlbumLabel {}
}

병합된 멤버의 가시성 규칙은 네임스페이스 병합 섹션에서 설명한 것과 동일하므로, 병합된 클래스가 보도록 AlbumLabel 클래스를 export 해야 합니다. 최종 결과는 다른 클래스 안에서 관리되는 클래스가 됩니다. 네임스페이스를 이용해 기존 클래스에 더 많은 static 멤버를 추가할 수도 있어요.

내부 클래스 패턴 외에도, 여러분은 함수를 만들고 나서 그 함수에 프로퍼티를 추가해 함수를 더 확장하는 JavaScript 관례에 익숙할 거예요. TypeScript는 선언 병합을 사용해 이런 정의를 타입 안전하게 구축합니다.

function buildLabel(name: string): string {
  return buildLabel.prefix + name + buildLabel.suffix;
}

namespace buildLabel {
  export let suffix = "";
  export let prefix = "Hello, ";
}

console.log(buildLabel("Sam Smith"));

비슷하게, 네임스페이스를 이용해 열거형을 static 멤버로 확장할 수도 있어요.

enum Color {
  red = 1,
  green = 2,
  blue = 4,
}

namespace Color {
  export function mixColor(colorName: string) {
    if (colorName == "yellow") {
      return Color.red + Color.green;
    } else if (colorName == "white") {
      return Color.red + Color.green + Color.blue;
    } else if (colorName == "magenta") {
      return Color.red + Color.blue;
    } else if (colorName == "cyan") {
      return Color.green + Color.blue;
    }
  }
}

허용되지 않는 병합 (Disallowed Merges)

TypeScript에서 모든 병합이 허용되는 것은 아닙니다. 현재 클래스는 다른 클래스나 변수와 병합할 수 없어요. 클래스 병합을 흉내 내는 방법은 TypeScript의 Mixins 섹션을 참고하세요.

모듈 증강 (Module Augmentation)

JavaScript 모듈은 병합을 지원하지 않지만, 객체를 import 해서 업데이트하는 방식으로 패치할 수는 있어요. 장난감 수준의 Observable 예시를 보겠습니다.

// observable.ts
export class Observable<T> {
  // ... implementation left as an exercise for the reader ...
}

// map.ts
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
  // ... another exercise for the reader
};

이것은 TypeScript에서도 잘 동작하지만, 컴파일러는 Observable.prototype.map을 알지 못합니다. 모듈 증강(module augmentation) 을 사용해 컴파일러에게 알려줄 수 있어요.

// observable.ts
export class Observable<T> {
  // ... implementation left as an exercise for the reader ...
}

// map.ts
import { Observable } from "./observable";
declare module "./observable" {
  interface Observable<T> {
    map<U>(f: (x: T) => U): Observable<U>;
  }
}
Observable.prototype.map = function (f) {
  // ... another exercise for the reader
};

// consumer.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
o.map((x) => x.toFixed());

모듈 이름은 import/export의 모듈 지정자와 같은 방식으로 해석됩니다. 자세한 내용은 Modules를 참고하세요. 그러면 증강(augmentation) 안의 선언들은 원래와 같은 파일에 선언된 것처럼 병합됩니다.

단, 명심해야 할 제약 두 가지가 있어요.

  1. 증강에서 새로운 최상위 선언을 선언할 수는 없습니다 — 기존 선언에 대한 패치만 가능해요.
  2. 기본 내보내기(default export)는 증강할 수 없고 이름 있는 내보내기만 가능합니다. (내보낸 이름으로 증강해야 하는데 default는 예약어이기 때문이에요 — 자세한 내용은 #14080 참고)

전역 증강 (Global augmentation)

모듈 안에서 전역 스코프에 선언을 추가할 수도 있어요.

// observable.ts
export class Observable<T> {
  // ... still no implementation ...
}

declare global {
  interface Array<T> {
    toObservable(): Observable<T>;
  }
}

Array.prototype.toObservable = function () {
  // ...
};

전역 증강은 모듈 증강과 같은 동작과 한계를 갖습니다.

더 알아보기 (Learn more)