Что такое TypeScript и как создавать типы в TypeScript

В этой статье я дам несколько советов по объявлению типов в TypeScript.

Что такое TypeScript?

TypeScript был представлен как язык, расширяющий возможности JavaScript. То есть, TypeScript – это в JavaScript + объявления типов + небольшие дополнения (private, public и protected поля классов, кортежи и перечисления).

Вот небольшой фрагмент кода, написанного на JavaScript:

function createUser(firstName, lastName, age) {
    return {
        firstName,
        lastName,
        age,
        get fullName() {
            return `${this.firstName} ${this.lastName}`;
        }
    };
}

Нет объявлений типов, нет безопасности

Это рабочий код. Имя аргумента age подразумевает, что он буде содержать числовое значение. Но что, если это не так очевидно?

function createOrder(address, user, status) {
    return {
        address,
        owner: user,
        status,
    }
}

Какие значения допустимы для переменной status?

В данном примере есть поле status, которое может быть задано как Pending, Completed или Cancelled. Но как обеспечить это? Конечно, мы всегда можем сделать примерно так:

function createOrder(address, user, status) {
    const statuses = ['Cancelled', 'Pending', 'Completed'];
    if (statuses.indexOf(status) === -1) {
        throw new TypeError(`Status can only be on of ${statuses.join(', ')}`);
    }

    return {
        address,
        owner: user,
        status,
    }
}

Мы добавили четыре строки кода для проверки типа. И они будут выполняться каждый раз, когда вызывается функция createOrder. Но редактор, который использует разработчик, по-прежнему, не будет знать, корректный код или нет. Эту проблему решает TypeScript:

function createOrder(address: string, user: User, status: 'Cancelled' | 'Pending' | 'Completed') {
    return {
        address,
        owner: user,
        status,
    }
}

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

Если вы хотите узнать о TypeScript как можно больше, обратитесь к официальной документации.

Слишком много типов

Рассмотрим приведенный ниже код:

class User {
    firstName: string;
    lastName: string;
    age: number;
}

enum OrderStatus {
    Pending = 'Pending',
    Completed = 'Completed',
    Cancelled = 'Cancelled', 
}

class Order<ProductType> {
    product: ProductType;
    status: OrderStatus = OrderStatus.Pending; // По умолчанию заказы ‘в обработке’
    owner: User;
}

class OrderManagerComponent {
    orderCount: number = 0;
    admin: User = new User();
    orders: Order<any>[] = [];

    changeAdmin(newAdmin: User): void {
        this.admin = newAdmin;
    }

    addOrder(order: Order<any>): void {
        this.orders.push(order);
    }

}

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

Вот так будет выглядеть код для использования выведения типов.

class User {
    firstName: string;
    lastName: string;
    age: number;
}

enum OrderStatus {
    Pending = 'Pending',
    Completed = 'Completed',
    Cancelled = 'Cancelled', 
}

class Order<ProductType> {
    product: ProductType;
    status = OrderStatus.Pending; 
    // Мы уже установили значение этому свойству из перечисления
    // TypeScript поймёт, что только значения этого перечисления допустимы для этого свойства 
    owner: User;
}

class OrderManagerComponent {
    orderCount = 0; 
    // если мы присваиваем свойству числовое значение, то оно тоже является числом
    // не нужно повторять это с объявлением типа    
    admin = new User();
    // тот же принцип действует для классов
    orders: Order<any>[] = [];
    // Но не работает с массивами. Массивы могут содержать элементы определенного типа данных, такие как number[],
    // Или смешанные типы. Например (number | string)[], или кортеж: [number, string]
    // Поэтому при использовании массивов нужно указать тип    

    changeAdmin(newAdmin: User): void {
        this.admin = newAdmin;
    }

    addOrder(order: Order<any>): void {
        this.orders.push(order);
    }

}

В этом примере кода отсутствуют некоторые объявления типов. Это связано с тем, что при инициализации мы уже сообщаем TypeScript тип данных.

let x = 8; // число
let user = new User(); // экземпляр класса User

Недостаточно типов

Если мы знаем о выведении типов, то можем написать такой код:

function createOrder<ProductType>(product: ProductType, status: OrderStatus) {
    const order = new Order();

    order.status = status;
    order.product = product;
    order.owner = new User();

    return order;
}

const order = createOrder('string', OrderStatus.Completed);

Мы предположили, что функция createProduct возвращает экземпляр класса Order<ProductType>. И TypeScript идентифицирует его тип. Но ProductType будет выведен в тип string, потому что мы вызвали createProduct со строкой. На первый взгляд это выглядит как неправильное использование. Но это не так.

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

class ProductWrapper<ProductType> {
    constructor(public product: ProductType) {}

    private metadata; // некоторые метаданные о продукте
}

Объявления класса содержит некоторые метаданные о продукте. Обратите внимание, что мы не изменяем класс Order. Он все еще может принимать обычный тип данных или экземпляр класса ProductWrapper. Но мы создали фабричную функцию без явно определенного возвращаемого типа данных. Вот как это происходит:

function createOrder<ProductType>(product: ProductType, status: OrderStatus) {
    const order = new Order();
    order.status = status;
    order.product = new ProductWrapper(product);
    order.owner = new User();
    return order;
}

При вызове этой функции можно предположить, что мы получим тип Order<T>. Но в действительности мы  получаем Order<ProductWrapper<T>>, и TypeScript даже не знает об этом. Поэтому:

Всегда объявляйте типы аргументов и возвращаемых значений функции.

Приведенный ниже код гораздо более очевиден и безопасен:

function createOrder<ProductType>(product: ProductType, status: OrderStatus): Order<ProductWrapper<ProductType>> {
    const order = new Order<ProductWrapper<ProductType>>();
    order.status = status;
    order.product = new ProductWrapper(product);
    order.owner = new User();
    return order;
}

Кортежи

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

const vector = [3, 5, 6];

Это трехмерный вектор.  Но мы можем вставить в него еще одно число, сделав его четырехмерным вектором. Это нормальное поведение для кортежа TypeScript:

const vector: [number, number, number] = [3, 5, 6];

vector.push(6);

Полностью корректный код

Некоторые программисты могут путать кортежи Python с кортежами TypeScript. Вот правило, которое позволяет избежать этого:

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

В приведенном выше примере лучше создать простой класс Vector и использовать его вместо кортежа.

Слишком обобщенно

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

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

class Store<StateShape> {
    constructor(
        private _state: StateShape,
    ) {}

    getState(): StateShape {
        return {...this._state}; 
        // мы не хотим возвращать ссылку на сам объект, поэтому возвращаем копию
    }
}

Но сторонний разработчик может предположить, что у него может быть хранилище для базового типа. Например, string?

const store = new Store('hello'); // мы инициализировали обобщённый класс строкой
const state = store.getState();

Вторая строка выдаст ошибку, потому что оператор rest не работает со строкой.

class Store<StateShape extends {[key: string]: any}> {
    constructor(
        private _state: StateShape,
    ) {}

    getState(): StateShape {
        return {...this._state}; 
        // теперь мы уверены в типе объекта 
    }
}

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

Практическое правило:

Будьте осторожны с обобщениями. Убедитесь, что они ограничены конкретными типами.

Комбинирование типов данных

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

function convertDate(date: Date | string) {

}

Функция, которая может принимать объект Date или строку с датой в формате ISO

Но иногда возникают сложности. Рассмотрим следующие два класса:

class A {
    someMethod() {}

    someOtherMethod() {}
}

class B {
    someMethod() {}

    someOtherMethodSpecificToThisClass() {}
}

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

function tricky(param: A | B) {
    param.someMethod();
}

В этом примере объявляется общий метод для обоих объектов. Но что, если нужно передать параметр someOtherMethod классу A и параметр someOtherMethodSpecificToThisClass классу B? Для этого можно использовать оператор instanceof:

function trickier(param: A & B) {
    if (param instanceof A) {
        param.someOtherMethod();
    } else {
        param.someOtherMethodSpecificToThisClass();
    }
}

Мы изменили объявление типа на A & B, чтобы обозначить, что теперь мы используем пересечение типов. Поэтому внутри функции могут использоваться методы как из класса A, так и из класса B. Но компилятор неожиданно выдаст ошибку.

Комбинирование типов данных

Тип ‘never’?

Мы определили тип аргумента param как A & B. Из-за этого компилятор предполагает, что первая проверка оператора instanceof истинна, и условие else никогда не будет исполнено.

Изменим объявление типа обратно на A | B. В результате аргумент param может иметь тип A, а в другом случае тип B.

Заключение

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

Сергей Бензенкоавтор-переводчик статьи «How to type with TypeScript»