TypeScript

Знакомимся с TypeScript

Питер Вогел

Продукты и технологии:

JavaScript, C#, TypeScript

В статье рассматриваются:

  • применение TypeScript для разработки приложений;
  • сходства и различия между TypeScript и JavaScript;
  • работа TypeScript с JavaScript.

Во многих отношениях полезно судить о TypeScript по его достоинствам. Спецификация языка TypeScript описывает его как «синтаксический сахар для JavaScript». Это верно и, по-видимому, важно для охвата целевой аудитории этого языка — разработчиков клиентского ПО, в настоящее время использующих JavaScript.

И вы действительно должны понимать JavaScript, прежде чем сможете разобраться в TypeScript. По сути, в спецификации языка (см. bit.ly/1xH1m5B) конструкции TypeScript часто описываются в терминах получаемого JavaScript-кода. Но не менее полезно рассматривать TypeScript как отдельный язык, имеющий некую общность с JavaScript.

Например, как и C#, TypeScript является языком с типизированными данными, что дает вам поддержку IntelliSense и проверки при компиляции, не говоря уж о других средствах. Как и C#, TypeScript включает обобщения и лямбда-выражения (или их эквиваленты).

Но TypeScript, конечно, не является C#. Понимание уникальных качеств TypeScript не менее важно понимания того, что именно у TypeScript общего с используемым вами в настоящее время языком для программирования на серверной стороне. Система типов TypeScript отличается (в сторону упрощения) от C#. TypeScript уникальным образом использует заложенные в него знания других объектных моделей и реализует наследование иначе, чем C#. А поскольку TypeScript компилируется в JavaScript, TypeScript имеет много общих основополагающих принципов с JavaScript (в отличие от C#).

Остается вопрос: «На каком языке лучше писать клиентский код — на TypeScript или JavaScript?».

TypeScript — язык с типизацией данных

В TypeScript не так уж много встроенных типов данных, которые можно использовать в объявлениях переменных: только string, number и boolean. Эти три типа являются подтипами типа any (что тоже можно использовать при объявлении переменных). Вы можете присваивать переменным, объявленным с помощью четырех типов, такие типы, как null или undefined, или проверять их на равенство этим типам. Кроме того, можно объявлять методы как void, указывая, что они не возвращают значение.

В этом примере переменная объявляется как string:

var name: string;

Вы можете расширить эту простую систему типов перечислимыми значениями и четырьмя видами объектных типов: interface, class, array и function. Так, в следующем коде определяется интерфейс (один вид объектного типа) с именем ICustomerShort. Этот интерфейс включает два члена: свойство Id и метод CalculateDiscount:

interface ICustomerShort
{
  Id: number;
  CalculateDiscount(): number;
}

Как и в C#, вы можете использовать интерфейсы при объявлении переменных и возвращаемых типов. ниже объявляется переменная cs с типом ICustomerShort:

var cs: ICustomerShort;

Вы также можете определять объектные типы как классы, которые в отличие от интерфейсов могут содержать исполняемый код. В этом примере определяется класс CustomerShort с одним свойством и одним методом:

class CustomerShort
{
  FullName: string;
  UpdateStatus( status: string ): string
  {
    ...манипулируем переменной status... 
    return status;
  }
}

Подобно более новым версиям C# при определении свойство не обязательно предоставлять код реализации. Достаточно простого объявления имени и типа. Классы могут реализовать один или более интерфейсов, как показано на рис. 1, где мой интерфейс ICustomerShort вместе с его свойством добавляется в мой класс CustomerShort.

Рис. 1. Добавление интерфейса в класс

class CustomerShort implements ICustomerShort
{
  Id: number;
  FullName: string;
  UpdateStatus(status: string): string
  {
    ...манипулируем переменной status...
    return status;
  }
  CalculateDiscount(): number
  {
    var discAmount: number;
    ...calculate discAmount...
    return discAmount;
  }
}

Как и C#, TypeScript является языком с типизированными данными.

Как видно на рис. 1, синтаксис реализации интерфейса в TypeScript столь же прост, как и в C#. Чтобы реализовать члены интерфейса, вы лишь добавляете члены с тем же именем вместо связывания имени интерфейса с релевантными членами класса. В этом примере я добавил Id и CalculateDiscount в класс для реализации ICustomerShort. TypeScript также позволяет использовать литералы объектного типа. В следующем коде переменная cst присваивается объектному литералу, содержащему одно свойство и один метод:

var csl = {
            Age: 61,
            HaveBirthday(): number
          {
            return this.Age++;
          }
        };

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

UpdateStatus( status: string ): { 
  status: string; valid: boolean }
{
  return {status: "New",
          valid: true
         };
}

Помимо объектных типов (class, interface, literal и array), вы также можете определять типы функций, которые описывают сигнатуру функции. В следующем коде переписан CalculateDiscount из моего класса CustomerShort для приема единственного параметра с именем discountAmount:

interface ICustomerShort
{
  Id: number;
  CalculateDiscount( discountAmount:
    ( discountClass: string, 
      multipleDiscount: boolean ) => number): number
}

Этот параметр определен с применением типа функции, который принимает два параметра (string и boolean) и возвращает тип number. Если вы разработчик на C#, то, возможно, находите этот синтаксис очень похожим на лямбда-выражение.

Класс, реализующий этот интерфейс, выглядел бы так, как показано на рис. 2.

Рис. 2. Этот класс реализует соответствующий интерфейс

class CustomerShort implements ICustomerShort
{
  Id: number;
  FullName: string;
  CalculateDiscount( discountedAmount:
    ( discountClass: string, 
      multipleDiscounts: boolean ) => number ): number
  {
    var discAmount: number;
    ...calculate discAmount...
    return discAmount;
  }
}

Как и недавние версии C#, TypeScript также логически распознает тип данных переменной по значению, которым инициализируется эта переменная. Ниже TypeScript предположит, что переменная myCust имеет тип CustomerShort:

var myCust= new CustomerShort();
myCust.FullName = "Peter Vogel";

Как и в C#, можно объявлять переменные, используя интерфейс, а затем присваивая переменную объекту, который реализует этот интерфейс:

var cs: ICustomerShort;
cs = new CustomerShort();
cs.Id = 11;
cs.FullName = "Peter Vogel";

Наконец, вы также можете использовать параметры типа (что подозрительно напоминает обобщения в C#), чтобы позволить вызывающему коду указать нужный тип данных. В этом примере я даю возможность коду, создающему класс, задавать тип данных свойства Id:

class CustomerTyped<T>
{
  Id: T;
}

А этот код указывает тип данных свойства Id как string до его использования:

var cst: CustomerTyped<string>;
cst = new CustomerTyped<string>();
cst.Id = "A123";

Чтобы изолировать классы, интерфейсы и другие открытые члены и избегать конфликтов имен, можно объявлять эти конструкции внутри модулей, напоминающих пространства имен в C#. Вам придется пометить те элементы, которые вы хотите сделать доступными другим модулям, ключевым словом export. Модуль на рис. 3 экспортирует два интерфейса и класс.

Рис. 3. Экспорт двух интерфейсов и одного класса

module TypeScriptSample
{
  export interface ICustomerDTO
  {
    Id: number;
  }
  export interface ICustomerShort extends ICustomerDTO
  {
    FullName: string;
  }
  export class CustomerShort implements ICustomerShort
  {
    Id: number;
    FullName: string;
  }

Чтобы использовать экспортируемые компоненты, имя компонента можно дополнять именем модуля, как в следующем примере:

var cs: TypeScriptSample.CustomerShort;

Или использовать ключевое слово import в TypeScript, чтобы указать сокращенное имя модуля:

import tss = TypeScriptSample;
...
var cs:tss.CustomerShort;

Гибкая типизация данных

Все это должно выглядеть знакомым, если вы программист на C#, кроме, возможно, обратного порядка объявления переменных (сначала имя переменной, потом тип данных) и объектных литералов. Однако практически вся типизация данных в TypeScript не является обязательной. В спецификации типы данных описываются как аннотации. Если вы опускаете типы данных (а TypeScript не удается логически распознать их), то они считаются по умолчанию типом any.

TypeScript также не требует строгого совпадения типов данных (datatype matching). Для определения совместимости TypeScript использует то, что в спецификации называют структурным порождением подтипов (structural subtyping). Это подобно тому, что часто называют утиной типизацией (duck typing). В TypeScript два класса считаются идентичными, если они имеют члены с одинаковыми типами. Вот класс CustomerShort, который реализует интерфейс ICustomerShort:

interface ICustomerShort
{
  Id: number;
  FullName: string;
}
class CustomerShort implements ICustomerShort
{
  Id: number;
  FullName: string;
}

А вот класс CustomerDeviant, внешне похожий на мой класс CustomerShort:

class CustomerDeviant
{
  Id: number;
  FullName: string;
}

Практически вся типизация данных в TypeScript не является обязательной.

Благодаря структурному порождению подтипов я могу применять CustomerDevient с переменными, определенными с помощью моего класса CustomerShort или интерфейса ICustomerShort. В следующих примерах CustomerDeviant используется взаимозаменяемо с переменными, объявленными как CustomerShort или ICustomerShort:

var cs: CustomerShort;
cs = new CustomerDeviant
cs.Id = 11;
var csi: ICustomerShort;
csi = new CustomerDeviant
csi.FullName = "Peter Vogel";

Такая гибкость позволяет присваивать объектные литералы в TypeScript переменным, объявленным как классы или интерфейсы, — при условии, что они структурно совместимы, как, например, здесь:

var cs: CustomerShort;
cs = {Id: 2,
      FullName: "Peter Vogel"
     }
var csi: ICustomerShort;
csi = {Id: 2,
       FullName: "Peter Vogel"
      }

Это уводит нас в специфические для TypeScript средства вроде видимых типов (apparent types), надтипов и подтипов, о чем я не стану здесь рассказывать. Эти средства позволили бы, например, CustomerDeviant иметь члены, отсутствующие в CustomerShort, и при этом не вызывать никаких ошибок в моем примере кода.

В TypeScript есть наследование классов

Спецификация TypeScript описывает язык как реализующий «цепочки прототипов шаблонов классов [using], что позволяет реализовать множество вариаций объектно-ориентированных механизмов наследования». На практике это означает, что TypeScript является не только типизированным, но и, по сути, объектно-ориентированным языком.

Аналогично тому, как интерфейс в C# может наследовать от базового интерфейса, интерфейс в TypeScript может расширять другой интерфейс, даже если этот другой интерфейс определен в другом модуле. В следующем примере я расширяю интерфейс ICustomerShort, чтобы создать новый интерфейс с именем ICustomerLong:

interface ICustomerShort
{
  Id: number;
}
interface ICustomerLong extends ICustomerShort
{
  FullName: string;
}

У интерфейса ICustomerLong будут два члена: FullName и Id. В новом интерфейсе члены исходного интерфейса появляются первыми. Поэтому мой интерфейс ICustomerLong эквивалентен интерфейсу:

interface ICustomerLongPseudo
{
  FullName: string;
  Id: number;
}

Классу, который реализует ICustomerLong, понадобятся оба свойства:

class CustomerLong implements ICustomerLong
{
  Id: number;
  FullName: string;
}

Классы могут расширять другие классы точно так же, как в случае интерфейсов. Класс на рис. 4 расширяет CustomerShort и добавляет новое свойство в определение. Он использует явные аксессоры get и set для определения свойств (хоть и не особо полезным способом).

Рис. 4. Свойства, определенные с помощью аксессоров get и set

class CustomerShort
{
  Id: number;
}
class CustomerLong extends CustomerLong
{
  private id: number;
  private fullName: string;
  get Id(): number
  {
    return this.id
  }
  set Id( value: number )
  {
    this.id = value;
  }
  get FullName(): string
  {
    return this.fullName;
  }
  set FullName( value: string )
  {
    this.fullName = value;
  }
}

TypeScript вводит в действие правила доступа к внутренним полям (вроде id и fullName) через ссылку на класс (this). Классы также могут иметь функции-конструкторы, которые включают только что внесенное в C# средство: автоматическое определение полей. Функция-конструктор в TypeScript-классе должна называться constructor, а ее открытые параметры автоматически определяются как свойства и инициализируются значениями, передаваемыми в этих параметрах. В следующем примере конструктор принимает один параметр — Company типа string:

export class CustomerShort implements ICustomerShort
{
  constructor(public Company: string)
  {       }

Поскольку параметр Company определен как открытый, класс также получает открытое свойство Company, инициализируемое значением, переданным в конструктор. Благодаря этой функциональности в следующем примере переменной comp будет присвоено «PH&VIS»:

var css: CustomerShort;
css = new CustomerShort( "PH&VIS" );
var comp = css.Company;

Объявление параметра конструктора закрытым создает внутреннее свойство, доступной только из кода членов класса через ключевое слово this. Если параметр не объявлен как открытый или закрытый, никакое свойство не генерируется.

TypeScript является не только типизированным, но и, по сути, объектно-ориентированным языком.

В вашем классе должен быть конструктор. Как и в C#, если вы не предоставляете конструктор, он создается за вас. Если ваш класс расширяет другой, любой создаваемый вами конструктор должен включать вызов super. Это вызывает конструктор в расширяемом классе. Пример ниже включает конструктор с вызовом super, который передает параметры конструктору базового класса:

class MyBaseClass
{
  constructor(public x: number, public y: number ) { }   
}
class MyDerivedClass extends MyBaseClass
{
  constructor()
  {
    super(2,1);
  }
}

Наследование в TypeScript работает по-другому

Все это выглядит вам знакомым, если вы программист на C#, за исключением некоторых странных ключевых слов (вроде extends). Но расширение класса или интерфейса — не совсем то же самое, что механизмы наследования в C#. В спецификации TypeScript используются обычные термины для расширяемого класса (базовый класс) и класса, расширяющего его (производный класс). Однако эта спецификация, например, ссылается на спецификацию наследования класса вместо употребления слова «наследование».

Прежде всего в TypeScript меньше вариантов, чем в C#, когда дело доходит до определения базовых классов. Вы не можете объявить класс или члены как не подлежащие перегрузке, как абстрактные или виртуальные (хотя интерфейсы обеспечивают во многом ту же функциональность, что и виртуальные базовые классы).

Нет никакого способа предотвратить наследование от некоторых членов. Производный класс наследует все члены базового класса, включая открытые и закрытые члены (все открытые члены базового класса могут быть перегружены, а закрытые — нет). Чтобы переопределить открытый член, просто определите член в производном классе с той же сигнатурой. Хотя вы можете пользоваться ключевым словом super для доступа к открытому методу из производного класса, это ключевое слово не позволяет обращаться к какому-либо свойству в базовом классе (но свойство можно переопределить).

TypeScript позволяет дополнять интерфейс простым объявлением интерфейса с идентичным именем и новыми членами. Это дает возможность расширять существующий JavaScript-код без создания нового именованного типа. В примере на рис. 5 определяется интерфейс ICustomerMerge через два разных определения интерфейса, а затем этот интерфейс реализуется в классе.

Рис. 5. Интерфейс ICustomerMerge определен через два определения интерфейса

interface ICustomerMerge
{
  MiddleName: string;
}
interface ICustomerMerge
{
  Id: number;
}
class CustomerMerge implements ICustomerMerge
{
  Id: number;
  MiddleName: string;
}

Классы могут расширять другие классы, но не интерфейсы. В TypeScript интерфейсы могут расширять и классы, но только таким способом, который использует наследование. Расширяя класс, интерфейс включает все члены класса (открытые и закрытые), но без реализаций в классе. На рис. 6 интерфейс ICustomer получит закрытый член id, открытый член Id и открытый член MiddleName.

Рис. 6. Расширенный класс со всеми членами

class Customer
{
  private id: number;
  get Id(): number
  {
    return this.id
  }
  set Id( value: number )
  {
    this.id = value;
  }
}
interface ICustomer extends Customer
{
  MiddleName: string;
}

Интерфейс ICustomer имеет существенное ограничение: вы можете использовать его только с классами, расширяющими тот же класс, который расширен интерфейсом (в данном случае это класс Customer). TypeScript требует, чтобы вы включали закрытые члены в интерфейс, который должен наследоваться от класса, расширяемого этим интерфейсом, а не заново реализовали их в производном классе. Новому классу, использующему интерфейс ICustomer понадобилось бы, например, предоставить реализацию для MiddleName (поскольку в интерфейсе он лишь указан). Разработчик, использующий ICustomer, мог бы выбрать либо наследование, либо переопределение открытых методов класса Customer, но не смог бы переопределить закрытый член id.

Классы могут расширять другие классы точно так же, как в случае интерфейсов.

В следующем примере показан класс (с именем NewCustomer), который реализует интерфейс ICustomer и расширяет класс Customer так, как это требуется. В этом примере NewCustomer наследует реализацию Id от Customer и предоставляет реализацию для MiddleName:

class NewCustomer extends Customer implements ICustomer
{
  MiddleName: string;
}

Эта комбинация интерфейсов, классов, реализации и расширения дает возможность определяемым вами классам контролировать расширение классов, определенных в других объектных моделях (подробности см. в разделе 7.3 языковой спецификации «Interfaces Extending Classes»). В сочетании со способностью TypeScript использовать информацию о других JavaScript-библиотеках это позволяет писать TypeScript-код, который работает с объектами, определенными в этих библиотеках.

TypeScript знает о ваших библиотеках

Помимо классов и интерфейсов, определенных в вашем приложении, вы можете предоставить TypeScript информацию о других объектных библиотеках. Это обрабатывается с помощью ключевого слова declare в TypeScript. Оно создает то, что в спецификации называют окружающими объявлениями (ambient declarations). Возможно, вам никогда не придется самостоятельно использовать ключевое слово declare, потому что вы можете найти файлы определений для большинства JavaScript-библиотек на сайте DefinitelyTyped (definitelytyped.org). Через эти файлы определений TypeScript может фактически «читать документацию» по библиотекам, с которыми вам нужно работать.

«Чтение документации», конечно, подразумевает, что вы получаете поддержку IntelliSense по типизированным данным и проверки этапа компиляции при использовании объектов, образующих библиотеку. Кроме того, это позволяет TypeScript при определенных обстоятельствах логически распознавать тип переменной по контексту, в котором она применяется. Благодаря файлу определений lib.d.ts, включенному в TypeScript, этот язык предполагает, что переменная anchor имеет тип HTMLAnchorElement в следующем коде:

var anchor = document.createElement( "a" );

Файл определений указывает, что это результат, возвращаемый методом createElement, когда ему передается строка «a». Зная, что anchor — это HTMLAnchorElement, TypeScript знает, что переменная anchor будет поддерживать, к примеру, метод addEventListener.

Логическое распознавание типов данных в TypeScript также работает с типами параметров. Например, метод addEventListener принимает два параметра. Второй из них — это функция, через которую addEventListener передает объект типа PointerEvent. TypeScript известно это, и он поддерживает доступ к свойству cancelBubble класса PointerEvent внутри функции:

span.addEventListener("pointerenter", function ( e )
{
  e.cancelBubble = true;
}

Аналогично тому, как lib.d.ts предоставляет информацию о HTML DOM, файлы определений для других JavaScript-библиотек обеспечивают сходную функциональность. После добавления файла backbone.d.ts в свой проект я могу, например, объявить класс, который расширяет класс Backbone Model и реализует собственный интерфейс следующим кодом::

class CustomerShort extends bb.Model implements ICustomerShort
{
}

Если вас интересуют подробности того, как использовать TypeScript с Backbone и Knockout, почитайте статьи в моей рубрике «Practical TypeScript» по ссылке bit.ly/1BRh8NJ. В новом году я буду рассматривать детали применения TypeScript с Angular.

TypeScript — нечто большее, чем вы видели в этой статье. В TypeScript версии 1.3 планируется включить тип данных union (например, для поддержки функций, которые возвращают список определенных типов) и кортежи (tuples). Группа TypeScript сотрудничает с другими группами, применяющими типизацию данных в JavaScript (Flow и Angular), чтобы гарантировать работоспособность TypeScript с максимально широким спектром JavaScript-библиотек.

Если вам нужно сделать что-то, что JavaScript позволяет, а TypeScript — нет, вы всегда можете интегрировать свой JavaScript-код, поскольку TypeScript является надмножеством JavaScript. Так что вопрос остается открытым: какой из этих языков вы предпочли бы использовать для написания клиентского кода?


Питер Вогел (Peter Vogel) — один из руководителей PH&V Information Services, специализируется в веб-разработке, эксперт по SOA, разработке клиентского ПО и дизайна UI. В число клиентов PH&V входят Canadian Imperial Bank of Commerce, Volvo и Microsoft. Он также занимается обучением и пишет учебные курсы для Learning Tree International. Ведет рубрику Practical .NET на сайте VisualStudioMagazine.com.

Выражаю благодарность за рецензирование статьи эксперту Microsoft Райену Кевено (Ryan Cavanaugh).