Skip to main content

TypeScript от Я

TypeScript — это тот же JavaScript, а не другой язык программирования. Он обратно совместим с JavaScript — после компиляции разработчик получит обычный JS. TypeScript отличается от JavaScript возможностью явного статического назначения типов.

Разработчик TypeScript — Андерс Хейлсберг (Anders Hejlsberg), создавший Turbo Pascal, Delphi и C#. Преимущества языка:

  • аннотации типов и проверка их согласования на этапе компиляции;
  • интерфейсы, кортежи, декораторы свойств и методов, расширенные возможности ООП;
  • широкая поддержка IDE и адекватный автокомплит;
  • поддержка ES6-модулей из коробки;
  • TypeScript — надмножество JavaScript, поэтому любой код на JavaScript будет выполнен и в TypeScript.

В первом случае (JS) мы видим просто функцию enableValidation, она принимает какой-то input и какой-то rules. Что это такое? Чем является input? DOM-элементом? Или входящей строкой? Непонятно.

// JS

function enableValidation(input, rules) {
// ...
}

Во втором случае всё более явно, когда вы привыкнете читать типы:

  • функция принимает input, это HTMLInputElement, то есть DOM-элемент;
  • rules — это массив функций, которые принимают строку и возвращают boolean;
  • вся функция enableValidation возвращает функцию, которая не возвращает ничего (можно предположить, что эта функция выключит валидацию).
// TS

function enableValidation(
input: HTMLInputElement,
rules: Array<(value: string) => boolean>
): () => void {
// ...
}

Установка

# Установка компилятора
npm install --save-dev typescript

# Компиляция файла
tsc helloworld.ts

# Компиляция всех файлов
npx tsc

# Утилита позволяет компилировать и сразу запускать .ts файлы
npm install --save-dev ts-node
ts-node script.ts

tsconfig.json

Для комфортной работы с компилятором можно описать все необходимые настройки с помощью файла конфигурации tsconfig.json:

{
"compilerOptions": {
"target": "es2016",
"outDir": "./dist",
"declaration": false,
"module": "commonjs",
"strictNullChecks": true,
"strict": "true",
"allowJs": true,
"sourceMap": true,
"esModuleInterop": true,
...
}
}

Must-have options:

"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"sourceMap": true,
"strictNullChecks": true

Чтобы облегчить переход на TS, делайте его постепенно, в несколько этапов:

  • После установки и настройки TS, в tsconfig.json выключите noImplicitAny и strictNullChecks. Для этого нужно присвоить им значение false. Далее поменяйте все расширения с .js на .ts. Типизировать сервер не обязательно, можете оставить его как есть. Запустите сборку проекта и исправьте ошибки, которые появились.
  • Теперь включите обратно noImplicitAny. Явный any используйте в самых крайних случаях. Лучше спросить у наставника и одногруппников, возможно, без него можно будет обойтись. Исправьте ошибки.
  • Последний шаг — снова включите strictNullChecks. Если в терминале ошибки, вы знаете, что делать. При запуске линтера используйте флаг --fix и не забывайте, что линтер можно настроить «под себя».

Примитивы

// Была просто константа
let a = 1;

// Стала константа с типом number
let a: number = 1;
// Была функция, которая принимает просто value
function makeJob(value) { /**/ }

// Стала функция, которая принимает value типа number
function makeJob(value: number) { /**/ }
// Была функция, которая непонятно что возвращает
function calcSum(a, b) { /**/ }

// Стала функция, которая принимает числа и возвращает число
function calcSum(a: number, b: number): number { /**/ }
// boolean
const isDone: boolean = false;
const valid: boolean = true;

// number
const decimal: number = 6 + 0xf00d + 0b1010;
const count: number = 42;

// string
const man: string = 'John Snow';
const sentence: string = 'Hello, my name is Alice.';

// null & undefined
const n: null = null;
const u: undefined = undefined;

Массивы и кортежи (tuple)

Массивы можно объявлять двумя равнозначными способами:

const list1: number[] = [1, 2, 3];

// generic type
const list2: Array<number> = [1, 2, 3];
// Объявление кортежа
let point: [number, number];

// Некорректная инициализация
// Type 'string' is not assignable to type 'number'.
point = [10, 'hello'];

// Инициализация
point = [20, 10]; // OK

Перечисления (enum)

Обратите внимание, что перечисления можно создавать двумя способами — enum и const enum. В скомпилированном коде enum превратится в объект. А вот const enum не сгенерирует новый код, но полностью удалится во время компиляции, и в местах использования значений из такого «енума» будут подставлены соответствующие значения. Больше примеров с константными перечислениями найдёте в документации.

enum Color { 
Red = 1,
Green,
Blue,
}

let colorName: string = Color[2];
console.log(colorName); // 'Green'

const enum Modes {
Show = 'show',
Edit = 'edit',
}

let modeName: string = Modes.Show;
console.log(modeName) // 'show'

Object.keys(Color); // Ok
Object.keys(Modes); // Error

Пример использования enum

enum LangType {
ru = 'ru',
en = 'en',
}

const getDefaultOptions = (lc: LangType): string[] => {
return [
lc === LangType.ru ? "Web-разработка" : "Web-development",
lc === LangType.ru ? "Мобильная разработка" : "Mobile",
lc === LangType.ru ? "1С-интеграция" : "1S integration",
lc === LangType.ru ? "UX/UI дизайн" : "UX/UI design",
]
}

export default getDefaultOptions;

Объекты

Объекты в TypeScript можно описывать с помощью ключевого слова object. Про это ключевое слово нужно знать, но не стоит использовать. Этот тип не говорит почти ни о чём ни компилятору, ни человеку. И последнее — даже важнее.

// Плохо — ничего не знаем про содержимое объекта
const colors: object = {
red: '#F00',
green: '#0F0',
blue: '#00F'
};

Намного лучше описывать объекты так, как они есть — со всеми полями и типами данных в них. Для этого используется такая запись:

const settings: {
color: string;
delay: number;
retry: boolean;
} = {
color: '#F00',
delay: 2000,
retry: false
};

Или через type:


type TSettings = {
color: string;
delay: number;
retry: boolean;
}

const settings: TSettings = {
color: '#F00',
delay: 2000,
retry: false
};

Record

Иногда бывают задачи, где не важно знать о ключах объекта (например, они динамически добавляются и удаляются), но важно знать, что в значениях лежат только числа (например делаем счётчик чего-то). В таких случаях поможет тип Record:

// Record<тип_ключа, тип_значения>
const counter: Record<string, number> = {
apple: 1,
orange: 8,
banana: 6,
grape: 5
};

Альтернативой Record может быть вот такая запись:

const counter: { [key: string]: number } = {
apple: 1,
orange: 8,
banana: 6,
grape: 5
};

Этот синтаксис стоит иметь в виду при работе с типами, но для продуктовых задач чаще хватает Record или явного описания объекта.


Опциональные ключи

В объектах также можно описать опциональные ключи — они могут быть, а могут не быть. Для этого используется символ ? после ключа:

// Это ок, поля price нет в объекте
const price: { name: string; price?: number } = {
name: 'Товар1'
};

// Это ок, поле price есть и имеет тип number
const price2: { name: string; price?: number } = {
name: 'Товар1',
price: 5
};

// Не ок, тип price не попадает в number
const price3: { name: string; price?: number } = {
name: 'Товар1',
price: '5'
};

Функции

Мы часто передаём функции как аргументы в другие функции или присваиваем переменным функции, возвращаемые другими функциями. TypeScript позволяет типизировать и это с помощью конструкции (аргументы) => возвращаемый_тип:

// Переменная имеет тип «Функция, не принимает аргументов, ничего не возвращает»
let cancelTimeoutCb: () => void;

// Функция принимает задержку и коллбэк, возвращет функцию
function makeTimeout(delay: number, cb: Function): () => void {
const timeoutId = setTimeout(() => {
cb();
}, delay);

return function(): void {
clearTimeout(timeoutId);
}
}

// Функции могут лежать в ключах объектов

const calculator: {
value: number;

// Тип поля plus - функция, принимающая и возвращающая число
plus: (v: number) => number;
} = {
value: 0,
plus: function(v: number): number {
this.value += v;
return this.value;
}
};

Объединение типов

Бывает так, что, например, переменная, поле или функция имеют больше одного типа. Как так? Например, бэкенд может вернуть объект, у которого поле price будет number или null, и надо быть готовым к любому из них. TypeScript позволяет это корректно описать и будет следить за использованием этого поля в дальнейшем. Описывается тип ИЛИ с помощью оператора |:

// Константа price содержит число или null
const price: number | null = null;

// Функция получения продукта возвращает объект, у которого price это number или null
function getProduct(): { name: string; price: number | null } {
return ...;
}

function discountPrice(product: { name: string; price: number | null }): number {
// Здесь TypeScript даст ошибку, потому что в price может быть null, и этот случай надо обработать
return price * 0.13;
}

any

В TypeScript живёт абсолютное, но иногда необходимое зло — тип any. Компилятор можно настроить так, чтобы он ругался на любое any в проекте. any говорит компилятору «забить на тип и поверить разработчику». Нельзя доверять людям или настраивать проект в полумере. Либо всё корректно, либо разработчики начинают оставлять «дырки» в виде any, которые потом будут «стрелять» и, что важнее, будут прецедентами для использования any в дальнейшем.

Частая фраза среди разных разработчиков: «но ведь в файле X это уже используется». Нельзя применять эту фразу как аргумент в пользу решения. Проект пишут разные люди при разных обстоятельствах. Если нет понимания, зачем существует тот или иной код, или почему он написан именно так, — лучше узнать отдельно, но самому написать правильно и как считаете корректным. Вернёмся к типу any:

let notSure: any = 4;
notSure = "maybe a string instead";
notSure = false;

let notSure: any = 4;
notSure.foo(); // TypeError: notSure.foo is not a function
notSure.toFixed(); // OK

let prettySure: object = new Number(4);
// Property 'toFixed' does not exist on type 'object'.
prettySure.toFixed();

unknown

Вместо any по умолчанию старайтесь использовать тип unknown, потому что язык будет ругаться на него, если «почувствует» что-то некорректное. Например, непонятно — есть ли у переменной свойство length или нет:

// any
let value: any;

value.foo.bar; // OK
value.trim(); // OK
value(); // OK
new value(); // OK
value[0][1]; // OK

// unknown
let value: unknown;

value.foo.bar; // Error
value.trim(); // Error
value(); // Error
new value(); // Error
value[0][1]; // Error

Защитник unknown

Применять тип unknown можно следующим образом:

function isNumberArray(value: unknown): value is number[] {
return (
Array.isArray(value) &&
value.every(element => typeof element === "number")
);
}

Конструкция value is number[] является защитником типа (Type Guard) и в данном случае говорит тайпскрипту, что если функция вернёт true, то переданная переменная является массивом чисел, и после этого с переменной можно работать как с известным типом:

function processNumbers(value: unknown) {
// Здесь TypeScript не позволит вызвать метод map, так как не знает, есть ли он у unknown
value.map(e => e * 2);
// Object is of type 'unknown'

// Если type guard вернёт false — прекратим выполнение функции
if (!isNumberArray(value)) {
return;
}

// Здесь TypeScript понимает, что если сюда добрались — значит, в value лежит массив чисел
// и всё будет работать
value.map(e => e * 2);
}

Если всё же не удаётся избежать использования any — старайтесь передвинуть его использование как можно ближе к источнику данных, инкапсулировать там, и дальше отдавать честные типы. Например, от бэкенда приходит какое-то значение, которое может быть чем угодно, но вы знаете, что в вашем приложении будете приводить это значение к числу или null. В этом случае тип any должен использоваться как можно ближе к обработке ответа бэкенда и не пускать свои корни в дальнейшую работу приложения:

// Тип any будет только тут, а на выходе из функции будет нормальный тип
function processResponse(response: any): number | null {
if (...) {
return null;
}

return parseInt(response);
}

void и never

Типы void и never отличаются следующим:

  • void говорит, что функция не возвращает никакого значения,
  • never говорит, что функция в каком-то случае может никогда не закончиться и никогда не вернуть результат. Например, while (true):
function warnUser(): void {
console.log("This is my warning message");
}

function error(message: string): never {
throw new Error(message);
}

function infiniteLoop(): never {
while (true) {}
}

Приведение типов (as)

Типы можно «кастить», или приводить к другим типам:

const someValue: any = 'this is a string';

const strLength2: number = (someValue as string).length;

Будьте осторожны с «кастингом» типов. Так вы говорите компилятору: «Здесь точно будет такой тип, доверься мне», а значит есть риск пропустить ошибку. И всё потому, что компилятор будет верить слову разработчика, а значит проверять это место менее тщательно.

В TypeScript всё так же работает деструктуризация, как и в JavaScript:

let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // outputs 1
console.log(rest); // outputs [ 2, 3, 4 ]

Язык позволяет использовать ключевое слово const, введённое в ES6.

const arr = [1, 2];
arr.push(3); // Ok
const constArr = [1, 2] as const;
constArr.push(3); // Error

Параметры по умолчанию

Описание функций становится более читабельным. Опишем снова функцию sum, на которую теперь не надо писать проверки типов аргументов:

function sum(x: number, y?: number): number {
if (y) {
return x + y;
} else {
return x;
}
}

console.log(sum(10, 8)); // 18
console.log(sum(42)); // OK! — 42

/////////////////////

function sum(x: number, y: number = 42): number {
return x + y;
}

console.log(sum(10, 8)); // 18
console.log(sum(42)); // OK! — 84

Когда аргументов может быть много

У функции можно описать неопределённое число аргументов:

// функция сумматор
function sum(...numbers: number[]): number {
return numbers.reduce((sum: number, current: number): number => {
sum += current; return sum;
}, 0);
}

console.log(sum(1, 2, 3, 4, 5)); // 15
console.log(sum(42, 0, -10, 5, 5)); // 42

Переопределение типов

function square(num: number): number;
function square(num: string): number;
function square(num: any): number {
if (typeof num === 'string') {
return parseInt(num, 10) * parseInt(num, 10);
} else {
return num * num;
}
}

Но можно написать и проще, используя объединение типов:

function square(num: string | number): number {
if (typeof num === 'string') {
return parseInt(num, 10) * parseInt(num, 10);
} else {
return num * num;
}
}

Типы

В прошлом уроке мы типизировали объект настроек:

const settings: {
color: string;
delay: number;
retry: boolean;
} = {
color: '#F00',
delay: 2000,
retry: false
};

Если этот объект часто используется — часто применяется и его тип. Описывать столько строчек кода в каждой функции, которая принимает или возвращает настройки, — дело неблагодарное и трудоёмкое. Чтобы этого избежать, придумали алиасы типов, которые позволяют описать удобным для нас словом целый тип и использовать его в дальнейшей работе. Type Alias — это не новый тип, а лишь синоним описанного объединения, пересечения или интерфейса.

type Settings = {
color: string;
delay: number;
retry: boolean;
};

const settings: Settings = {
color: '#F00',
delay: 2000,
retry: false
};

function applySettings(settings: Settings): void { /**/ }
function getCurrentSettings(): Settings { /**/ }

Интерфейсы

Интерфейс определяет свойства и методы, которые объект или класс должен реализовать. Возьмём предыдущий пример с settings и используем интерфейс, описав в нём структуру:

interface Settings {
color: string;
delay: number;
retry: boolean;
}

const settings: Settings = {
color: '#F00',
delay: 2000,
retry: false
};

function applySettings(settings: Settings): void { /**/ }
function getCurrentSettings(): Settings { /**/ }

При некорректном использовании появятся ошибки:

interface Options {
color?: string;
}

interface Square {
color: string;
}

function create(options: Options): Square {
const square: Square = {
color: 'white'
};

// Type 'undefined' is not assignable to type 'string'.
square.color = options.color;

/*
Вот так ошибки не будет:
if (options.color) {
square.color = options.color;
}
*/

return square;
}

Интерфейсы можно наследовать:

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

interface Square extends Figure {
square: () => number;
}

Относительно интерфейсов есть пара вопросов, вызывающих споры:

  • Называть интерфейсы с префиксом I или нет? User или IUser?
  • Использовать интерфейсы или type alias для типизации объектов? А если type alias — зачем интерфейсы?

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

Что касается использования типов или интерфейсов — здесь пользователи TypeScript придерживаются позиции, что объекты лучше типизировать с помощью type.

Во-первых, потому что эта запись имеет больше возможностей. Во-вторых, следуя логике из «взрослых» языков: интерфейс — это контракт поведения, и он должен использоваться вместе с ключевым словом implements, когда класс реализует интерфейс. Но опять же — вы должны сами для себя решить, как и что использовать, жёстких норм нет.


Классы

Как и в ES6 — есть возможность работать с классами. TypeScript добавил помимо типов отличную работу с ООП, наследованием, абстрактными классами и интерфейсами.

interface MakesSound {
makeSound(): void;
}

class Python implements MakesSound {
private readonly _length: number;

constructor(length: number) {
this._length = length;
}

public get length(): number {
return this._length / 100;
}

protected makeSound() {
console.log('Ssssss!');
}
}

У методов и свойств классов появились модификаторы доступов:

  • public — доступны без ограничений, это значение по умолчанию;
  • private — доступны только внутри класса;
  • protected — доступны внутри класса и в классах-наследниках.
  • readonly — комбинируется со всеми предыдущими, не даёт перезаписывать свойство. Оно должно быть или задано изначально, или задано в конструкторе.

Таким образом, модификаторы доступа работают по тем же правилам, что и в C++, C# и других типизированных языках.


Абстрактные классы

В TypeScript появились абстрактные классы. Нельзя создать экземпляр такого класса, однако его свойства и методы можно наследовать. Абстрактный класс нужен для описания или реализации обязательных методов.

abstract class Snake {
private readonly _length: number;

public get length(): number {
return this._length / 100;
}

constructor(length: number) {
this._length = length;
}

protected abstract makeSound(): void;
}

class Python extends Snake {
private static population = 10000;

public static incrementPopulation(): void {
Python.population++;
}

constructor(length: number) {
super(length);
Python.incrementPopulation();
}

// если не написать реализацию метода абстрактного класса,
// компилятор выдаст ошибку.
protected makeSound(): void {
console.log('Ssssss!');
}
}

Декораторы

Декораторы в JavaScript и TypeScript — это не миф, а реальность:

function memoize (target, key, descriptor) {
const originalMethod = descriptor.value;
const cache = {};
descriptor.value = function (n: number): number {
return cache[n] ? cache[n] : cache[n] = originalMethod(n);
}
}

class Utils {
@memoize
static fibonacci (n: number): number {
return n < 2 ? 1 : Utils.fibonacci(n - 1) + Utils.fibonacci(n - 2)
}
}

console.time('count');
console.log(Utils.fibonacci(50));
console.timeEnd('count') // оооочень долго

console.log(Utils.fibonacci(1000)); // 7.0330367711422765e+208
console.timeEnd('count'); // count: 5.668ms

Обобщение типов (ХЗ что это такое???)

Если в TypeScript создать массив из двух разных типов, то при добавлении третьего — произойдёт ошибка компиляции:

const shapes = [new Circle(), new Square()];

// Argument of type 'Triangle'
// is not assignable to parameter of type 'Square | Circle'.
shapes.push(new Triangle());

Автоматически срабатывает вывод типов. По объявлению массива он определил, что в массиве должны быть данные только двух типов. Если явно объявить, какие типы данных ожидаются, компилятору не придётся вычислять это самостоятельно. Например, если представить, что есть некий Shape, в котором явно указано, что в массиве может в том числе быть Triangle:

const shapes: Shape[] = [new Circle(), new Square()];
shapes.push(new Triangle()); // Ok

Теперь всё корректно. Автоматического вывода не будет, TypeScript будет заранее знать, Triangle — это тоже допустимое значения в массиве:

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

class Pet {
name: string;
constructor(value: string) {
this.name = value;
}
}

class Friend {
name: string;
constructor(value: string) {
this.name = value;
}
}

const pet: Pet = new Friend('Шарик'); // 🐶

Джененрик

const fib: Array<number> = [1, 1, 2, 3, 5];

// Argument of type 'string'
// is not assignable to parameter of type 'number'.
fib.push('1');

const map: Map<number, string> = new Map();

// Argument of type 'number'
// is not assignable to parameter of type 'string'.
map.set(1, 1);

Использовать дженерики можно также в type alias:

type AsyncResult<TResult> = Promise<TResult> | TResult;

let result: AsyncResult<string> = Promise.resolve('200');
let result: AsyncResult<string> = '200';
Рассмотрим более сложный пример:

interface ISwim {
swim()
}

class Dog implements ISwim {
swim() { ... }
}

class Duck implements ISwim {
swim() { ... }
}

function swimTogether<
T1 implements ISwim,
T2 implements ISwim
>(firstPal: T1, secondPal: T2) {
firstPal.swim();
secondPal.swim();
}
Generics можно проверять на более узкие типы:

type TypeName<T> =
T extends string ? 'string' :
T extends number ? 'number' :
T extends boolean ? 'boolean' :
T extends undefined ? 'undefined' :
T extends Function ? 'function' :
'object';

Такой подход называется conditional types.


TypeScript Declaration Files

TypeScript Declaration Files (*.d.ts) — это файлы для описания интерфейсов.

Основная проблема: не все JS-библиотеки имеют совместимость с TypeScript. И при использовании JS-файлов в TypeScript сталкиваемся с ошибками типизации, так как TypeScript не знает, что за функция, какие у неё свойства и функция ли это. Для этого есть .d.ts файлы, в которых описываются только интерфейсы и API библиотеки.

Например, раньше не было интерфейсов для JQuery, но их нужно было использовать. Приходилось писать:

interface JQueryStatic {
ajax(settings: JQueryAjaxSettings): JQueryXHR;
(element: Element): JQuery;
(html: string, ownerDocument?: Document): JQuery;
(): JQuery;
}

declare var $: JQueryStatic;
declare module 'jquery' {
export = $;
}

Сегодня к большинству JS-библиотек разработчиками написаны .d.ts файлы и их можно сразу использовать на любом языке, но не для всего. Знать такой способ стоит.

С помощью .d.ts можно описывать глобальные переменные окружения или инструменты, которые написали сами и хочется вынести в отдельную библиотеку или пакет.


JSDoc

Типизировать также можно с помощью JSDoc и TypeScript:

// Пример типизирования функции с помощью JSDoc + TypeScript
/**
* @param p0 {string} - Строковый аргумент, объявленный на манер TS
* @param {string} p1 - Строковый аргумент
* @param {string=} p2 - Опциональный аргумент
* @param {string} [p3] - Другой опциональный аргумент
* @param {string} [p4="test"] - Аргумент со значением по умолчанию
* @return {string} Возвращает строку
*/
function fn3(p0, p1, p2, p3, p4){
// TODO
}

Документации JSDoc достаточно, чтобы начать писать документируемый код. Он не всегда читабельный. Например, за ним сложно следить в крупном JS-проекте, но без него ещё сложнее. Альтернатива — сразу использовать нормальную типизацию. Либо Flow, либо TypeScript.


Parcel + TypeScript

Чтобы настроить TypeScript в сборщике Parcel, вам нужно сделать примерно... Ничего.

Да-да, вы не ослышались. Из коробки в Parcel уже живёт TypeScript. Всё, что вам нужно сделать:

  • Поменять расширения файлов на .ts.
  • Поменять расширение у скрипта в index.js на index.ts:
<script type="text/javascript" src="src/index.ts"></script>

Команда запуска и сборки не меняется. Так же запускаете команду запуска dev-режима и продолжаете разрабатывать на TypeScript.


Типизация react-компонента через generic

const Clip: React.FC<{ fill?: string; width?: number; height?: number }> = ({
fill,
width,
height,
}) => {
return (

Ссылки

Тема «TypeScript I» - Проблемы со стандартами

Отсутствие строгой типизации

Варианты решения проблем

Компилятор Babel и его playground, где можно посмотреть, как инструмент компилирует современный код под различные версии и настройки.]()

Отдельные языки со своими компиляторами:

Типизированный JS:

TypeScript

Типы данных, вывод и приведение

Полезные утилиты типов:

ООП

Обобщение типов

Parcel + TypeScript = Дружба?

Серия докладов Ильи Климова про типизацию: