Серия

В первой статье «TypeScript: Введение» я попытался кратко рассказать вам про предпосылки, которые привели к созданию TypeScript, его устройстве и проблемах, которые этот язык с лёгкостью решает. В этой статье я затрону тему типов и других близких по смыслу конструкций, которые делают TypeScript по-настоящему типизированным языком.

Типы в JavaScript

Напомню, что хотя JavaScript является динамически типизируемым языком, типы там всё же присутствуют – просто их проверка и присваивание автоматически происходит во время выполнения кода. Я думаю, что всем хорошо известны типы в JavaScript. На момент написания статьи (2017 год) их аж целых семь штук:

  • Boolean
  • Number
  • String
  • Object
  • Null
  • Undefined
  • Symbol

Все эти типы присутствуют в TypeScript, но с некоторым оговорками и дополнениями. Однако, об этом попозже.

Общие типы в TypeScript

Как я сказал выше, в TypeScript присутствуют все валидные для JavaScript типы, но их назначение дополняется или ограничивается в зависимости от настроек компилятора. Кроме того, в TypeScript есть и другие типы, которые позволяют писать хорошо типизированный код.

В этой статье я не буду затрагивать такие типы как boolean, string, number и symbol, потому что их поведение полностью совпадает с поведением соответствующих типов в JavaScript. Однако, на их примере мы рассмотрим несколько интересных конструкций, которые нужно понять прежде, чем вы начнёте писать типизированный код. Без этих конструкций можно насоздавать столько функций и переменных, что TypeScript лишь замедлит ваш конвейер по написанию кода.

Итак, приступим.

Тип объединения (Union)

С помощью конструкции Union вы можете создать новую структуру (тип), которая будет являться объединением нескольких указанных типов. В TypeScript конструкция объединения имеет следующий синтаксис:

const isUnionType: boolean | string | number = true;
const isUnionType: boolean | string | number = 'string';
const isUnionType: boolean | string | number = 123;

const isUnionType: boolean | string | number = {};
// Error → Type '{}' is not assignable to type 'boolean | string | number'.

Конструкцию вида boolean | string | number следует читать как: в этой переменной может находиться значение типа boolean, string или number, но не другого типа.

Тип пересечения

Эта конструкция позволяет комбинировать несколько типов в один. Это означает, что если вы создадите два разных объекта и захотите написать одну общую функцию для них, то вам потребуется что-то универсальное, что может принимать два разных типа и возвращать их комбинацию. Соответственно, это может работать и с классами. Например, так это работает с объектами:

function extend<T, U>(a: T, b: U): T & U {
    return Object.assign({}, a, b);
}

// Совмещает тип T и U
const obj = extend({ one: 1 }, { two: 2 });

const one = obj.one; // 1
const two = obj.two; // 2

Здесь T и U – это некоторые типы T и U, соответственно, которые захватываются при вызове функции. Можно использовать любые буквы, которые вам хочется, но буквы на входе и выходе функции должны совпадать. Также, обратите внимание на конструкцию <T, U> после имени функции. Так как типы T и U в TypeScript не существуют и они не были объявлены где-то выше, то мы должны указывать их явно. Такая конструкция называется «обобщение» (generic) и подсказывает компилятору, что на входе мы имеем два типа, которые можно применить в теле функции, но эти типы он должен определить сам на основании того, как будет вызвана функция. Подробнее об обобщениях мы поговорим в следующей статье.

Конструкцию вида T & U нужно читать как: тип, который объединяет в себе тип T и тип U. Для закрепления можно провести аналогию с логическими операторами: && – логическое и, то есть тип пересечение и || – логическое или, то есть тип объединение.

Приведение типов или защитники типов

Спешу вас обрадовать, в TypeScript все типы должны быть приведены явно, то есть везде нужны явные действия меняющие тип, иначе компилятор будет ругаться. Для этого были придуманы «защитники типов».

Внимательно посмотрите на код ниже. В этом примере мы создаём новый экземпляр класса String и пытаемся присвоить его переменной типа string, на что получаем ошибку. Зачем мы хотим это сделать? – не знаю. Просто до нужных типов мы ещё с вами не дошли. Поэтому просто хотим.

const str: string = new String('TypeScript');
// Error → Type 'String' is not assignible to type 'string'.

Конечно, логично предположить, что это два разных типа (примитив и обёртка над Object). Для того, чтобы всё заработало, нужно использовать «защитника типа», который явно укажет компилятору, что экземпляр класса String – это тип string и ему не нужно бросаться в нас ошибками.

const str: string = <string>new String('TypeScript');

В случае работы с методами класса – нужно обернуть его экземпляр в скобки и указать «защитника». В примере ниже функция getSmallPet возвращает экземпляр класса Fish или Bird. Напомню, что рыбка умеет плавать, а вот летать пока не научилась. Относительно птички нужно сказать, что это домашняя птица, поэтому плавать она не умеет. Врядли вы будете держать в квартире гуся. Хотя рыбок и птичек у меня никогда не было, поэтому, может быть домашние рыбки уже и летать научились.

const pet = getSmallPet();

if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
} else {
    (<Bird>pet).fly();
}

Разумеется, что в конечном JavaScript-файле (после компиляции) никаких скобок не будет – эта информация нужна лишь компилятору и он молча её за собой приберёт.

Также, существуют typeof и instanceof защитники, которые позволяют компилятору понимать какой сейчас тип следует использовать при анализе вашего кода. Вариант использования этих защитников ограничен лишь функциями и конструкциями вида if и case.

В случае typeof защитника:

let isStringOrNumber: string | number;

if (typeof isStringOrNumber === 'string') {
    isStringOrNumber.includes('me'); // string
} else {
    isStringOrNumber.toExponential(); // number
}

В случае instanceof защитника:

if (pet instanceof Fish) {
    pet.swim(); // Fish
} else {
    pet.fly(); // Bird
}

И, наконец, вы можете определить своего защитника, используя предикат. Этот вариант предпочтительнее, если вы часто будете просить свою рыбку плавать, а птичку – летать. В коде ниже происходит буквально следующее: так как на вход функции может прийти экземпляр класса Fish или Bird – компилятор затрудняется определить какой класс ему использовать для проверки кода. То есть, конструкция instanceof внутри функции ему ничего не говорит – это простое возвращение true или false. Однако, так как мы указали предикат pet is Fish, компилятор начинает догадоваться, что, если функция возвращает true, то сейчас он имеет место с экземпляром класса Fish, иначе, если false – экземпляром класса Bird.

function isFish(pet: Fish | Bird): pet is Fish {
    return pet instanceof Fish;
}

if (isFish(pet)) {
    pet.swim();
} else {
    pet.fly();
}

Псевдонимы типов

Наверное, вы устали после «защитников типов», поэтому немного расслабимся и кратенько поговорим про псевдонимы типов перед тем как перейти к типам в TypeScript.

Ну что сказать, тут всё довольно просто: в TypeScript вы можете создавать псевдонимы типов, например, так:

type RepositoryOwner = string;
type PullRequestNumber = string | number;

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

Ну что, отдохнули от всяких вспомогательных конструкций для работы с типами? Я думаю, что нет, потому что дальше больше и местами сложнее. Переходим к типам.

Типы в TypeScript

Вжух и вот мы говорим о типах в TypeScript. Как вы могли уже заметить, для работы с типами существует несколько синтаксических конструкций. Самое время поговорить про основные типы, которые будут приследовать вас всё время, пока вы будете писать код на TypeScript. Опять таки повторюсь, что типы boolean, string, number и symbol здесь рассматриваться не будут.

Тип null и undefined

В TypeScript null и undefined являются субтипами. Субтипы – это типы, которые могут быть присвоены любому другому типу, например, string или number.

const isString: string = null;
const isNumber: number = undefined;
const isNull: null = null;
const isNull: null = undefined;

При большом желании вы можете попросить компилятор быть с вами построже и назначить null и undefined самостоятельными типами. В этом случае вы не сможете присвоить null или undefined переменной с типом string, если явно не укажете, что эта переменная может принимать оба этих типа, используя ранее рассмотренную конструкцию объединения – Union.

const isString: string = null;
// Error → Type 'null' is not assignable to type 'string'.

const isString: string = undefined;
// Error → Type 'undefined' is not assignable to type 'string'.

const isString: string | null = null;
const isString: string | undefined = undefined;

Тип object

Тип object в TypeScript определяет все не-примитивные типы, то есть тип, который не является number, string, boolean, symbol, null или undefined. Этот тип был введён в TypeScript 2.2, который вышел в феврале 2017 года – раньше для определения типа нужно было либо использовать any, либо писать интерфейсы. Но об этом чуть позже.

Также, в TypeScript существует тип Object, который включает в себя все JavaScript-объекты. Такой тип подразумевает наличие метода hasOwnProperty и других стандартных методов у объекта.

const isObject: object = 1;
// Error → Type '1' is not assignable to type 'object'.

const isObject: object = {};
const isObject: Object = 1;
const isObject: Object = {};

Тип any

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

let isAny: any = 1;

isAny = 'string'
isAny = {};

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

Тип void

Обычно этот тип используется для запрета возвращения значения из функции. Если быть точным, то он указывает компилятору, что здесь не должно быть никакого типа. Хорошим примером будет функция, выводящая что-либо в console.log:

function warn(message: string): void {
    console.warn(message);
}

Но многие забывают, что по умолчанию TypeScript считает null и undefined субтипами, поэтому void не запрещает возвращать их из функции. Но вы можете попросить компилятор считать эти субтипы самостоятельными типами. В этом случае void запретит возвращать null, но не undefined. Пример ниже не вызывает ошибки компиляции:

function pitfall(): void {
    return;
}

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

Тип never

Тип Never указывает на то, что функция ни при каких обстоятельствах не может ничего вернуть из себя, например, она всегда бросает ошибку или включает в себя бесконечный цикл, который не может ничего вернуть из функции. Этот тип является субтипом и может быть присвоен любому типу, но не наоборот.

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

Здесь важно понять отличие never от void, потому что поначалу это может сбивать с толку. Функция, которая ничего не возвращает – это void, но как мы убедились выше, она может вернуть undefined. Функция, которая никогда ничего не возращает – это never.

function pitfall(): never {
    return;
}
// Error → Type 'undefined' is not assignable to type 'never'.

В голове never можно представить себе как вариант запрета использовать ключевое слово return в функции.

Тип строкового литерала

Этот тип позволит вам указать точное значение строки, которые может быть присвоено переменной. Рассматривать этот тип имеет смысл лишь в случае использования с Union, потому что этот тип сам по себе смысловой нагрузки не несёт и конструкции типа const str: 'hello' = 'hello' бесполезны в реальном мире.

let isStringLiteral: 'one' | 'two' | 'three';

isStringLiteral = 'one';
isStringLiteral = 'four';
// Error → Type '"four"' is not assignable to type '"one" | "two" | "three"'.

Тип литерала объекта

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

const isObject: { prop: string } = {
    prop: 'string'
};

Однако, я настоятельно рекомендую использовать в таких случаях интерфейсы, о которых мы поговорим в конце этой статьи.

Полиморфный тип this или F-ограниченный полиморфизм

Полиморфный тип this регламентирует отношение «тип — подтип», когда ограниченно полиморфный тип должен быть подтипом некоторого более общего типа. Сложное объяснение. Попробую как-нибудь попроще. Полиморфный тип this представляет собой субтип содержащего класса или интерфейса. Буквально этот тип можно понять как «объект этого класса или любого класса, наследованного от него». Используется в том случае, если метод возвращает заранее неизвестный тип. При наследовании тип this будет соответствовать типу наследника.

class Animal {
    walk(): this {
        return this;
    }
}

class Panda extends Animal {
    sleep(): this {
        return this;
    }
}

let panda: Panda;
const result = panda.sleep().walk(); // instanceOf Panda

При этом this в типе возвращаемого значения можно не писать, так как он автоматически определяем.

Структуры

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

Тип Array или типизированный массив

Ничего особенного – просто возможность указать, что в переменной находится массив с определённым типом данных у его элементов:

const isArrayOfStrings: string[] = ['a', 'b'];
const isArrayOfNumbers: number[] = [1, 2];

const isArrayOfBooleans: boolean[] = [true, 0, 1];
// Error → Type '(true | 0 | 1)[]' is not assignable to type 'boolean[]'

Вы также можете использовать более обобщённый (generic) вариант записи:

const isArrayOfStrings: Array<string> = ['a', 'b'];

Тип Tuple или кортеж

С помощью типа «кортеж» вы можете объявить массив известной длины с известными типами в качестве его элементов. Обычно такая структура используется предоставления одного набора данных, например, записи в базе данных.

const tuple: [number, string, boolean] = [1234, 'TypeScript Types', true];
const tuple: [string, string[]] = ['javascript', ['boolean', 'string']];

const tuple: [number, string, boolean] = [1234, 'TypeScript Types', {}];
// Error → Type '[number, string, {}]' is not assignable to type '[number, string, boolean]'.

При извлечении элемента из такого массива TypeScript автоматически будет определять его тип на основе описания. Если вы попытаетесь извлечь из кортежа элемент, выходящий за его рамки, то будет возвращаться элемент с неопределённым типом или, как мы уже знаем, с объединённым типом (Union).

const tuple: [number, string] = [1234, 'string'];

const item = tuple[0]; // number
const item = tuple[1]; // string
const item = tuple[2]; // number | string

Тип Enum или перечисление

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

enum Days { Sat, Sun, Mon, Tue, Wed, Thu, Fri };

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

enum Days { Sat=1, Sun, Mon, Tue, Wed, Thu, Fri };

В TypeScript можно не только задавать константные индексы, но и вычисляемые:

enum Days { Sat=1, Sun, Mon, Tue, Wed, Thu, Fri, G = 'TypeScript'.length };

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

enum Days { Sat=1, Sun, Mon, Tue, Wed, Thu, Fri };

const sun: string = Days[2]; // Sun

В сгенерированном коде enum хранится в виде объекта:

var Days;
(function (Days) {
    Days[Days["Sat"] = 1] = "Sat";
    Days[Days["Sun"] = 2] = "Sun";
    Days[Days["Mon"] = 3] = "Mon";
    Days[Days["Tue"] = 4] = "Tue";
    Days[Days["Wed"] = 5] = "Wed";
    Days[Days["Thu"] = 6] = "Thu";
    Days[Days["Fri"] = 7] = "Fri";
})(Days || (Days = {}));

var sun = Days[2]; // Sun

Также существует константный вариант записи enum, который может использовать в качестве перечислителя только константные выражение. В этом случае компилятор не будет создавать дополнительный объект – индексы сразу будут расставлены в коде.

const enum Directions {
    Up,
    Down,
    Left,
    Right
};

const directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];

Этот код будет скомпилирован в более простой форме:

var directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */];

Заметьте, что компилятор добавил комментарии для каждого имени перечислителя – это делает код читаемым во время отладки и редактируемым даже в случае утраты исходников.

Любопытный читатель может задать вопрос, а зачем мне вообще может понадобиться такая структура как enum? Ответ очень прост. Представьте, что вы пишете парсер, причём любой, вообще не важно какого языка или даже просто логов. Парсер перебирает каждый символ строки и для того, чтобы определить какая конструкция перед вами, нужно этот символ с чем-то сравнить. И тут на выбор у разработчика два пути: куча переменных или enum. В случае последнего, вы можете писать инструкции вида:

if (node.type === NodeType.OpenParetheses) {
    // ...
}

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

Интерфейсы

Как я уже говорил, TypeScript многое почерпнул из C#. Ещё одним примером такого тесного сотрудничества будут интерфейсы.

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

Объявление интерфейса начинается с ключевого слова interface. Затем идёт имя интерфейса, которые принято начинать с заглавной буквы I. Это не обязательное условие использования интерфейсов, но я рекомендую вам придерживаться этого соглашения, хотя бы потому, что это очень удобно.

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

Рассмотрим простейший пример интерфейса для объекта, описывающего какой-то VPS-сервер:

interface IServer {
    hostname: string;
    location: string;
    active: boolean;
    public_address: string;
}

Теперь, когда мы определили интерфейс, мы можем использовать его в переменной. Обычно принято говорить не «использовать интерфейс», а «реализовать интерфейс», так как компилятор проверяет правильность реализации интерфейса. И, в случае неверной реализации (нет хотя бы одного свойства), он будет бросать в нас ошибками каждый раз, когда мы делаем что-то не так. Для примера, давайте забудем указать свойство public_address.

const server: IServer = {
    hostname: 'Pikachu',
    location: 'RM1',
    active: true
}
// Error → Type '{ hostname: string; location: string; active: true; }' is not assignable to type 'IServer'.
//          Property 'public_address' is missing in type '{ hostname: string; location: string; active: true; }'.

Компилятор оценил наши начинания, но не увидел свойства public_address в нашем объекте, что вынудило его кинуть в нас ошибкой.

Но не всегда какую-то структуру можно выделить используя лишь один интерфейс. Разумеется, что в TypeScript можно использовать один интерфейс как тип для свойства другого интерфейса.

interface IPublicAddress {
    netmask: string;
    gateway: string;
    address: string;
}

interface IServer {
    hostname: string;
    location: string;
    active: boolean;
    public_address: IPublicAddress;
}

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

interface IServer {
    getPublicAddres: () => IPublicAddress;
}

При этом никто не запрещает вам указывать параметры функции:

interface ICalculator {
    sum: (a: number, b: number) => number;
}

Расширение интерфейсов

В TypeScript вы можете лишь расширять интерфейсы, но не наследовать их. Расширение применяется в том случае, если вам нужно, чтобы новый интерфейс имел не только все свойства какого-то интерфейса, но и имел дополнительные или уникальные для этого интерфейса свойства.

interface IResponse {
    status: number;
}

interface ISlackResponse extends IResponse {
    ok: boolean;
}

Индексируемые типы

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

interface ICache {
    size: number;
    first: ICacheItem;
    last: ICacheitem;
    items: {
        [item: string]: ICacheItem;
    };
}

Теперь вы сможете записывать любое значение в объект items, которые имеет ключ типа строка и значение типа ICacheItem.

Имплементация интерфейса

В случае с объектом вы просто присваиваете переменной через двоеточие нужный вам интерфейс в качестве типа и реализуете его. С классами несколько иначе – классы должны имплементировать интерфейсы. Если вы забудете написать реализацию хотя бы одного метода, то компилятор выдаст ошибку. Для примера напишем интерфейс кэша, который будет имплементирован классом.

interface ICacheItem {
    mtime: number;
    content: string;
}

interface IFileCache {
    set: (key: string, value: ICacheItem) => void;
    get: (key: string) => ICacheItem;
}

class FileCache implements IFileCache {
    store = new Map();

    set(key: string, value: ICacheItem): void {
        this.store.set(key, value);
    }

    get(key: string): ICacheItem {
        return this.store.get(key);
    }
}

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

Тип дискриминируемого объединения

Я не зря поместил этот тип в этом разделе статьи, потому что он относится только к интрефейсам. Тип дискриминируемого объединения возвращает новый тип, который содержит в себе лишь те свойства, что присутствуют во всех интерфейсах.

Ниже представлены три интерфейса, у которых есть одно общее свойство, тип которого представлен в виде строкового литерала. Именно это свойство будет представлено в новом типе.

interface Square {
    kind: 'square';
    size: number;
}

interface Rectangle {
    kind: 'rectangle';
    width: number;
    height: number;
}

interface Circle {
    kind: 'circle';
    radius: number;
}

Теперь посмотрим на то, как мы можем использовать дискриминирующее объединение на практике. Как вы уже, наверное, догадались, мы будем вычислять площадь фигур, которые описаны с помощью интерфейсов.

function area(s: Square | Rectangle | Circle): number {
    switch (s.kind) {
        case 'square': return s.size * s.size;
        case 'rectangle': return s.height * s.width;
        case 'circle': return Math.PI * s.radius ** 2;
    }
}

Мы также могли бы использовать if для того, чтобы компилятор понял, с каким интерфейсом мы работаем в данный момент.

function area(s: Shape): number {
    if (s.kind === 'square') {
        return s.size * s.size;
    }
    // ...
}

Теперь поговорим про тот случай, когда функция может принимать не только квадрат, прямоугольник и круг, но и треугольник. При этом, для треугольника нет соответствующей реализации. В этом случае компилятор не выдаст ошибку, если null и undefined являются субтипами (ещё помните об этом?). Однако, если они являются полноправными типами, нам придётся либо указать number | undefined в качестве возвращаемого функцией типа, либо добавить default в случае использования case, либо else в случае исполользования if.

Пару слов после статьи

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

В следующей, небольшой по объёму статье, мы более детально поговорим про «обобщения» (generics), которые позволяют писать универсальные функции, захватывающие тип в зависимости от их вызова.