声明合并

声明是编程语言中的基础结构,它描述了一个标识符所表示的含义。在 TypeScript 语言中,一个标识符总共可以有以下三种含义:

  • 表示一个值。

  • 表示一个类型。

  • 表示一个命名空间。

下例中,我们分别定义了值、类型和命名空间。其中,常量 zero 属于值,接口 Point 属于类型,而命名空间 Utils 则属于命名空间。示例如下:

const zero = 0;

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

namespace Utils {}

对于同一个标识符而言,它可以同时具有上述多种含义。例如,有一个标识符 A,它可以同时表示一个值、一种类型和一个命名空间。示例如下:

const A = 0;

interface A {}

namespace A {}

在同一声明空间内使用的标识符必须唯一。TypeScript 语言中的大部分语法结构都能够创建出新的声明空间,例如函数声明和类声明都能够创建出一个新的声明空间。最典型的声明空间是全局声明空间和模块声明空间。当编译器发现同一声明空间内存在同名的声明时,会尝试将所有同名的声明合并为一个声明,即声明合并;若发现无法进行声明合并,则会产生编译错误。声明合并是 TypeScript 语言特有的行为。在进行声明合并时,编译器会按照标识符的含义进行分组合并,即值和值合并、类型和类型合并以及命名空间和命名空间合并。但是并非所有同名的声明都允许进行声明合并,例如,常量声明a和函数声明a之间不会进行声明合并。

接下来,将具体介绍 TypeScript 语言中的声明合并,包括接口声明、枚举声明、类声明、命名空间声明、扩充模块声明、扩充全局声明。

接口声明合并

接口声明为标识符定义了类型含义。在同一声明空间内声明的多个同名接口会合并成一个接口声明。下例中,存在两个A接口声明,它们属于同一声明空间。编译器会将两个 A 接口声明中的类型成员合并到一起,合并后的接口 A 等同于接口 MergedA。示例如下:

interface A {
    a: string;
}

interface A {
    b: number;
}

interface MergedA {
    a: string;
    b: number;
}

若待合并的接口中存在同名的属性签名类型成员,那么这些同名的属性签名必须是相同的类型,否则会因为合并冲突而产生编译错误。例如,在下例的两个接口 A 中,属性成员 a 的类型分别为 number 类型和 string 类型,在合并接口时会发生冲突。示例如下:

interface A {
    a: number;
}

interface A {
    a: string; // 编译错误
}

若待合并的接口中存在同名的方法签名类型成员,那么同名的方法签名类型成员会被视为函数重载,并且靠后定义的方法签名具有更高的优先级。例如,下例的两个 A 接口声明中都定义了方法签名类型成员 f。在合并后的接口 A 中,方法签名f具有两个重载签名并且后声明的 f 方法(第 6 行)具有更高的优先级。示例如下:

interface A {
    f(x: any): void;
}

interface A {
    f(x: string): boolean;
}

interface MergedA {
    f(x: string): boolean;
    f(x: any): void;
}

合并重载签名的基本原则是后声明的重载签名具有更高优先级。但也存在一个例外,若重载签名的参数类型中包含字面量类型,则该重载签名具有更高的优先级。例如,下例中尽管第 2 行的方法签名 f 是先声明的,但在接口合并后仍具有更高的优先级,因为它的函数签名中带有字面量类型。示例如下:

interface A {
    f(x: 'foo'): boolean;
}

interface A {
    f(x: any): void;
}

interface MergedA {
    f(x: 'foo'): boolean;
    f(x: any): void;
}

若待合并的接口中存在多个调用签名类型成员或构造签名类型成员,那么它们将被视作函数重载和构造函数重载。与合并方法签名类型成员相同,后声明的调用签名类型成员和构造签名类型成员具有更高的优先级,同时也会参考参数类型中是否包含字面量类型。示例如下:

interface A {
    new (x: any): object;
    (x: any): any;
}

interface A {
    new (x: string): Date;
    (x: string): string;
}

interface MergedA {
    new (x: string): Date;
    new (x: any): object;
    (x: string): string;
    (x: any): any;
}

当涉及重载时,接口合并的顺序变得尤为重要,因为它将影响重载的解析顺序。

若待合并的接口中存在多个字符串索引签名或数值索引签名,则将产生编译错误。在合并接口时,所有的接口中只允许存在一个字符串索引签名和一个数值索引签名。示例如下:

interface A {
    [prop: string]: string;
}

interface A {
    [prop: number]: string;
}

interface MergedA {
    [prop: string]: string;
    [prop: number]: string;
}

若待合并的接口是泛型接口,那么所有同名接口必须有完全相同的类型参数列表。若待合并的接口存在继承的接口,那么所有继承的接口会被合并为单一的父接口。在实际程序中,我们应避免复杂接口的合并行为,因为这会让代码变得难以理解。

枚举声明合并

多个同名的枚举声明会合并成一个枚举声明。在合并枚举声明时,只允许其中一个枚举声明的首个枚举成员省略初始值。示例如下:

enum E {
    A,
}

enum E {
    B = 1,
}

enum E {
    C = 2,
}

let e: E;
e = E.A;
e = E.B;
e = E.C;

此例中,第 2 行的首个枚举成员省略了初始值,因此 TypeScript 会自动计算初始值。第 6 行和第 10 行,必须为首个枚举成员定义初始值,否则将产生编译错误,因为编译器无法在多个同名枚举声明之间自动地计算枚举值。

枚举声明合并的另外一点限制是,多个同名的枚举声明必须同时为 const 枚举或非 const 枚举,不允许混合使用。示例如下:

// 正确
enum E0 {
    A,
}
enum E0 {
    B = 1,
}

// 正确
const enum E1 {
    A,
}
const enum E1 {
    B = 1,
}

// 编译错误
enum E2 {
    A,
}
const enum E2 {
    B = 1,
}

类声明合并

TypeScript 不支持合并同名的类声明,但是外部类声明可以与接口声明进行合并,合并后的类型为类类型。示例如下:

declare class C {
    x: string;
}

interface C {
    y: number;
}

let c: C = new C();
c.x;
c.y;

命名空间声明合并

命名空间的声明合并会稍微复杂一些,它可以与命名空间、函数、类和枚举进行合并。

命名空间与命名空间合并

与合并接口类似,同名的命名空间也会进行合并。例如,编译器会将下例中的两个 Animals 命名空间进行合并,合并后的命名空间等同于命名空间 MergedAnimals。示例如下:

namespace Animals {
    export class Bird {}
}

namespace Animals {
    export interface CanFly {
        canFly: boolean;
    }
    export class Dog {}
}

namespace MergedAnimals {
    export interface CanFly {
        canFly: boolean;
    }
    export class Bird {}
    export class Dog {}
}

如果存在嵌套的命名空间,那么在合并外层命名空间时,同名的内层命名空间也会进行合并。示例如下:

namespace outer {
    export namespace inner {
        export var x = 10;
    }
}
namespace outer {
    export namespace inner {
        export var y = 20;
    }
}

namespace MergedOuter {
    export namespace inner {
        export var x = 10;
        export var y = 20;
    }
}

在合并命名空间声明时,命名空间中的非导出成员不会被合并,它们只能在各自的命名空间中使用。示例如下:

namespace NS {
    const a = 0;

    export function foo() {
        a;  // 正确
    }
}

namespace NS {
    export function bar() {
        foo(); // 正确

        a;  // 编译错误:找不到 'a'
    }
}

命名空间与函数合并

同名的命名空间声明与函数声明可以进行合并,但是要求函数声明必须位于命名空间声明之前,这样做能够确保先创建出一个函数对象。函数与命名空间合并就相当于给函数对象添加额外的属性。示例如下:

function f() {
    return f.version;
}

namespace f {
    export const version = '1.0';
}

f();   // '1.0'
f.version; // '1.0'

命名空间与类合并

同名的命名空间声明与类声明可以进行合并,但是要求类声明必须位于命名空间声明之前,这样做能够确保先创建出一个构造函数对象。命名空间与类的合并提供了一种创建内部类的方式。示例如下:

class Outer {
    inner: Outer.Inner = new Outer.Inner();
}

namespace Outer {
    export class Inner {}
}

我们也可以利用命名空间与类的声明合并来为类添加静态属性和方法。示例如下:

class A {
    foo: string = A.bar;
}

namespace A {
    export let bar = 'A';
    export function create() {
        return new A();
    }
}

const a: A = A.create();
a.foo; // 'A'
A.bar; // 'A'

命名空间与枚举合并

同名的命名空间声明与枚举声明可以进行合并。这相当于将枚举成员与命名空间的导出成员进行合并。示例如下:

enum E {
    A,
    B,
    C,
}

namespace E {
    export function foo() {
        E.A;
        E.B;
        E.C;
    }
}

E.A;
E.B;
E.C;
E.foo();

需要注意的是,枚举成员名与命名空间导出成员名不允许出现同名的情况。示例如下:

enum E {
    A,                     // 编译错误!重复的标识符 A
}

namespace E {
    export function A() {} // 编译错误!重复的标识符 A
}

扩充模块声明

对于任意模块,通过模块扩充语法能够对模块内的已有声明进行扩展。例如,在 a.ts 模块中定义了一个接口 A,在 b.ts 模块中可以对 a.ts 模块中定义的接口 A 进行扩展,为其增加新的属性。

假设有如下目录结构的工程:

C:\app
|-- a.ts
`-- b.ts

a.ts 模块文件的内容如下:

export interface A {
    x: number;
}

b.ts 模块文件的内容如下:

import { A } from './a';

declare module './a' {
    interface A {
        y: number;
    }
}

const a: A = { x: 0, y: 0 };

此例中,declare module './a' {} 是模块扩充语法。其中,'./a' 表示要扩充的模块名,它与第一行模块导入语句中的模块名一致。

我们使用模块扩充语法对导入模块 './a' 进行了扩充。第 4 行定义的接口 A 将与 a.ts 模块中的接口 A 进行声明合并,合并后的结果仍为接口 A,但是接口 A 增加了一个属性成员 y

在进行模块扩充时有以下两点需要注意:

  • 不能在模块扩充语法中增加新的顶层声明,只能扩充现有的声明。也就是说,我们只能对 './a' 模块中已经存在的接口 A 进行扩充,而不允许增加新的声明,例如新定义一个接口 B

  • 无法使用模块扩充语法对模块的默认导出进行扩充,只能对命名模块导出进行扩充,因为在进行模块扩充时需要依赖于导出的名字。

扩充全局声明

与模块扩充类似,TypeScript 还提供了全局对象扩充语法 declare global {}。示例如下:

export {};

declare global {
    interface Window {
        myAppConfig: object;
    }
}

const config: object = window.myAppConfig;

此例中,declare global {} 是全局对象扩充语法,它扩展了全局的 Window 对象,增加了一个 myAppConfig 属性。第 1 行,我们使用了 export {} 空导出语句,这是因为全局对象扩充语句必须在模块或外部模块声明中使用,当我们添加了空导出语句后,该文件就成了一个模块。

全局对象扩充也具有和模块扩充相同的限制,不能在全局对象扩充语法中增加新的顶层声明,只能扩充现有的声明。