Перейти к содержанию

Теоретические основы файлов определений: глубокое погружение

Описать модуль так, чтобы он имел в точности необходимый API, может оказаться сложной задачей. К примеру, может понадобиться модуль, который вызывается с new или без, создавая при этом разные типы, имеет различные именованные типы, упорядоченные иерархически, и несколько свойств на самом объекте модуля.

Прочитав это руководство, вы получите инструменты для написания сложных файлов определений, предоставляющих удобный API. Это руководство уделяет основное внимание модульным (или UMD) библиотекам из-за множества их вариантов и разнообразия.

Ключевые принципы

Для того, чтобы понять, как придать определению любую форму, нужно понять ключевые принципы работы TypeScript.

Типы

Если вы читаете это руководство, то, наверное, уже примерно представляете, что такое тип в TypeScript. Но для большей ясности укажем, что тип вводится с помощью:

  • Объявления псевдонима типа (type sn = number | string;)
  • Объявления интерфейса (interface I { x: number[]; })
  • Объявления класса (class C { })
  • Объявления перечисления (enum E { A, B, C })
  • Объявления import, которое ссылается на тип

Каждое из таких объявлений создает новое имя типа.

Значения

Как и с типами, вы, скорее всего, понимаете, что такое значения. Значения — это имена, на которые можно ссылаться в выражениях во время выполнения кода. К примеру, let x = 5; создает значение под именем x.

Опять же, для ясности, укажем, что значения создаются следующими конструкциями:

  • объявлениями let, const и var.
  • объявлениями namespace или module, внутри которых содержится значение
  • объявлением enum
  • объявлением class
  • объявлением import, которое ссылается на значение
  • объявлением function

Пространства имен

Типы могут существовать внутри пространств имен. К примеру, если взять объявление let x: A.B.C, то можно сказать, что тип C находится в пространстве имен A.B.

Здесь есть тонкий и важный момент — A.B не обязательно является типом или значением.

Простые сочетания: одно имя, несколько значений

Взяв имя A, можно прийти к одному из трех вариантов того, что оно означает: тип, значение или пространство имен. Как интерпретируется имя, зависит от контекста, в котором оно используется. К примеру, в объявлении let m: A.A = A; имя A в первый раз используется как пространство имен, потом как имя типа, а затем как значение. Разные варианты могут приводить к указанию на совершенно разные объявления!

Все это может показаться запутанным, но на самом деле очень удобно, если только имена не слишком перегружаются. Посмотрим на полезные стороны такого поведения сочетаний.

Встроенные сочетания

Проницательный читатель мог заметить, что, например, class появляется и в списке типов, и в списке значений. Определение class C { } создает две вещи: тип C, который ссылается на форму экземпляра класса, и значение C, которое ссылается на функцию-конструктор для данного класса. Определения перечислений ведут себя так же.

Пользовательские сочетания

Допустим, мы написали файл модуля 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
}

Это дает хорошую возможность для деструктуризации в использующем модуль коде:

import { Bar } from './foo'
let x: Bar = Bar.a
console.log(x.count)

Здесь мы использовали Bar и как тип, и как значение. Отметим, что не обязательно определять значение Bar как имеющее тип Bar — они независимы.

Сложные сочетания

Некоторые объявления могут сочетаться между несколькими объявлениями. Например, class C { } и interface C { } могут сосуществовать, и оба добавлять свойства к типам C.

Это допустимо, пока не создает конфликтов. Правило таково, что значения всегда конфликтуют с другими значениями с тем же именем, если только они не объявлены как namespace; типы конфликтуют, если объявлены с помощью псевдонима типа (type s = string), а пространства имен не конфликтуют никогда.

Посмотрим, как это можно использовать.

Добавление с помощью interface

К интерфейсу можно добавить члены с помощью другого объявления interface:

interface Foo {
  x: number;
}
// ... где-то в другом месте ...
interface Foo {
  y: number;
}
let a: Foo = ...;
console.log(a.x + a.y); // OK

Это работает и с классами:

class Foo {
  x: number;
}
// ... где-то в другом месте ...
interface Foo {
  y: number;
}
let a: Foo = ...;
console.log(a.x + a.y); // OK

Отметим, что с помощью интерфейса нельзя добавить что-либо к псевдониму типа (type s = string;).

Добавление с помощью namespace

Объявление namespace можно использовать для добавления новых типов, значений и пространств имен, если это не создает конфликтов.

К примеру, можно добавить статический член к классу:

class C {}
// ... где-то в другом месте ...
namespace C {
  export let x: number
}
let y = C.x // OK

В данном примере значение было добавлено к статической части C (функции-конструктору). Так произошло потому, что мы добавили значение, а контейнером для значения может служить только другое значение (типы содержатся в пространствах имен, а пространства имен — в других пространствах имен).

Тем же способом к классу можно добавить тип:

class C {}
// ... где-то в другом месте ...
namespace C {
  export interface D {}
}
let y: C.D // OK

В этом примере пространства имен C не существовало, пока мы не написали объявление namespace для него. C в смысле пространства имен не конфликтует со значениями или типами под именем C, которые создаются объявлением класса.

И наконец, с помощью объявлений namespace можно осуществлять различные виды слияний. Следующий пример не очень реалистичен, но показывает все разновидности интересного поведения:

namespace X {
  export interface Y {}
  export class Z {}
}

// ... где-то в другом месте ...
namespace X {
  export var Y: number
  export namespace Z {
    export class C {}
  }
}
type X = string

В этом примере первый блок создает следующие смыслы для имен:

  • Значение X (поскольку объявление пространства имен содержит значение, Z)
  • Пространство имен X (поскольку объявление пространства имен содержит тип, Y)
  • Тип Y в пространстве имен X
  • Тип Z в пространстве имен X (часть экземпляра класса)
  • Значение Z, которое является свойством значения X (функция-конструктор класса)

Второй блок создает следующие смыслы:

  • Значение Y (с типом number), которое является свойством значения X
  • Пространство имен Z
  • Значение Z, которое является свойством значения X
  • Тип C внутри пространства имен X.Z
  • Значение C, которое является свойством значения X.Z
  • Тип X

Использование с export = или import

Важно, что объявления export и import экспортируют или импортируют все смыслы того, на что ссылаются.

Ссылки