Deep Dive: 선언 파일 이론
Deep Dive: 선언 파일 이론 (Declaration File Theory)
원하는 API 모양 그대로를 드러내는 모듈을 설계하는 건 생각보다 까다로워요. 예를 들어 new로 호출할 수도, 그냥 호출할 수도 있으면서 서로 다른 타입을 만들어 내는 모듈이 필요하다거나, 계층 구조 안에 여러 이름 붙은 타입들을 노출시키면서도 모듈 객체 위에 프로퍼티까지 갖고 싶다고 해볼게요. 이 글을 끝까지 읽고 나면, 이렇게 복잡한 선언 파일을 작성해서 사용자에게 친근한 API 표면을 드러낼 수 있는 도구들이 손에 잡힐 거예요. 이 가이드가 주로 집중하는 건 모듈(또는 UMD) 라이브러리예요 — 옵션이 여기서 훨씬 다양하거든요.
출처: TypeScript 공식문서
본문
핵심 개념 (Key Concepts)
TypeScript가 동작하는 핵심 개념 몇 가지를 이해하면, 어떤 모양의 선언이든 자유자재로 만들 수 있어요. 먼저 이 기초부터 하나씩 짚어볼게요.
타입 (Types)
이 가이드까지 읽고 계신 분이라면 TypeScript에서 타입이 뭔지 대략 알고 계시겠죠. 좀 더 명확하게 말하자면, 타입은 다음과 같은 선언으로 도입됩니다.
- 타입 별칭 선언 (
type sn = number | string;) - 인터페이스 선언 (
interface I { x: number[]; }) - 클래스 선언 (
class C { }) - 열거형 선언 (
enum E { A, B, C }) - 타입을 가리키는
import선언
이 선언 형태들 각각이 새로운 타입 이름을 만들어 냅니다.
값 (Values)
타입 때와 마찬가지로, 값이 뭔지도 이미 아실 거예요. 값은 표현식 안에서 참조할 수 있는 런타임 이름이에요. 예를 들어 let x = 5;는 x라는 값을 만들어 내죠.
역시 명확하게 말하자면, 다음 항목들이 값을 만들어 냅니다.
let,const,var선언- 값을 담고 있는
namespace또는module선언 enum선언class선언- 값에 해당하는
import선언 function선언
네임스페이스 (Namespaces)
타입은 네임스페이스 안에도 존재할 수 있어요. 예를 들어 let x: A.B.C라는 선언이 있다면, 타입 C가 A.B 네임스페이스에서 온 것이라고 말하죠.
이 구분은 아주 미묘하면서도 중요한데요 — 여기서 A.B는 반드시 타입이나 값이어야 하는 건 아니에요.
조합의 기초: 한 이름, 여러 의미 (Simple Combinations: One name, multiple meanings)
A라는 이름 하나를 생각해 보면, 거기에는 타입이나 값, 네임스페이스라는 서로 다른 의미가 최대 세 개까지 담길 수 있어요. 이름이 어떻게 해석되느냐는 그것이 쓰인 문맥에 달려 있죠. 예를 들어 let m: A.A = A;라는 선언에서는 A가 먼저 네임스페이스로, 그다음 타입 이름으로, 마지막으로 값으로 쓰여요. 이 의미들이 어쩌면 완전히 다른 선언들을 가리키고 있을 수도 있죠.
좀 헷갈리게 들릴 수 있는데, 사실은 꽤 편리한 성질이에요 — 과하게 오버로딩만 하지 않는다면 말이죠. 이 결합 동작의 유용한 면들을 몇 가지 살펴볼게요.
내장된 조합 (Built-in Combinations)
눈치 빠른 분들은 class가 타입 목록과 값 목록 둘 다에 들어 있다는 걸 알아채셨을 거예요. class C { } 선언은 두 가지를 만들어 내요. 하나는 클래스의 인스턴스 형태를 가리키는 타입 C, 그리고 다른 하나는 클래스의 생성자 함수를 가리키는 값 C죠. 열거형 선언도 비슷하게 동작합니다.
사용자 조합 (User Combinations)
foo.d.ts라는 모듈 파일 하나를 쓴다고 해볼게요.
export var SomeVar: { a: SomeType };
export interface SomeType {
count: number;
}
그다음에 이걸 이렇게 소비한다고 해요.
import * as foo from "./foo";
let x: foo.SomeType = foo.SomeVar.a;
console.log(x.count);
이것도 충분히 잘 동작하지만, SomeType과 SomeVar가 서로 아주 밀접해서 같은 이름을 쓰고 싶다면 어떨까요? 이때 결합을 이용해서 값과 타입이라는 서로 다른 두 객체를 Bar라는 한 이름 아래에 둘 수 있어요.
export var Bar: { a: Bar };
export interface Bar {
count: number;
}
이렇게 하면 소비하는 쪽 코드에서 구조 분해(destructuring)를 쓰기 딱 좋아져요.
import { Bar } from "./foo";
let x: Bar = Bar.a;
console.log(x.count);
다시 말하지만 여기서 Bar를 타입과 값 양쪽으로 썼어요. 그리고 Bar 값을 Bar 타입으로 선언할 필요는 없었다는 점도 눈여겨보세요 — 둘은 서로 독립적이거든요.
고급 조합 (Advanced Combinations)
어떤 종류의 선언들은 여러 번에 걸쳐 결합될 수 있어요. 예를 들어 class C { }와 interface C { }는 함께 존재할 수 있고, 둘 다 C 타입에 프로퍼티를 보태요.
충돌만 만들지 않는다면 이건 합법이에요. 대략적인 규칙은 이렇죠. 값은 namespace로 선언되지 않는 한 같은 이름의 다른 값과 항상 충돌하고, 타입은 타입 별칭 선언(type s = string)으로 선언된 경우에만 충돌하며, 네임스페이스는 절대 충돌하지 않아요.
이걸 어떻게 활용하는지 볼게요.
interface로 덧붙이기 (Adding using an interface)
interface 선언으로 다른 interface에 멤버를 추가할 수 있어요.
interface Foo {
x: number;
}
// ... elsewhere ...
interface Foo {
y: number;
}
let a: Foo = ...;
console.log(a.x + a.y); // OK
이건 클래스에서도 동작해요.
class Foo {
x: number;
}
// ... elsewhere ...
interface Foo {
y: number;
}
let a: Foo = ...;
console.log(a.x + a.y); // OK
한 가지 주의할 점은, 타입 별칭(type s = string;)에는 interface로 덧붙일 수 없다는 거예요.
namespace로 덧붙이기 (Adding using a namespace)
namespace 선언은 충돌만 만들지 않는다면 어떤 방식으로든 새로운 타입, 값, 네임스페이스를 추가하는 데 쓸 수 있어요.
예를 들어 클래스에 정적 멤버를 추가해볼게요.
class C {}
// ... elsewhere ...
namespace C {
export let x: number;
}
let y = C.x; // OK
이 예시에서 우리는 C의 정적 쪽(생성자 함수)에 값을 더했어요. 값은 다른 값에 담기니까요 — 타입은 네임스페이스에, 네임스페이스는 또 다른 네임스페이스에 담기는데, 모든 값의 그릇은 값이기 때문이에요.
클래스에 네임스페이스화된 타입을 추가할 수도 있어요.
class C {}
// ... elsewhere ...
namespace C {
export interface D {}
}
let y: C.D; // OK
이 예시에서는 우리가 namespace 선언을 쓰기 전까지 네임스페이스 C는 존재하지 않았어요. 네임스페이스로서의 C라는 의미는 클래스가 만든 값·타입 의미의 C와 충돌하지 않아요.
마지막으로, namespace 선언을 써서 아주 다양한 병합(merge)을 수행할 수도 있어요. 실용적인 예시는 아니지만, 흥미로운 동작들을 여럿 보여주죠.
namespace X {
export interface Y {}
export class Z {}
}
// ... elsewhere ...
namespace X {
export var Y: number;
export namespace Z {
export class C {}
}
}
type X = string;
이 예시에서 첫 번째 블록은 다음과 같은 이름 의미들을 만들어 내요.
- 값
X(네임스페이스 선언이Z라는 값을 담고 있으니까) - 네임스페이스
X(네임스페이스 선언이Y라는 타입을 담고 있으니까) X네임스페이스 안의 타입YX네임스페이스 안의 타입Z(클래스의 인스턴스 형태)X값의 프로퍼티인 값Z(클래스의 생성자 함수)
두 번째 블록은 다음과 같은 이름 의미들을 만들어 내요.
X값의 프로퍼티인 값Y(number타입)- 네임스페이스
Z X값의 프로퍼티인 값ZX.Z네임스페이스 안의 타입CX.Z값의 프로퍼티인 값C- 타입
X