Выживание в экосистеме TypeScript - Часть 5: Защита типов и дискриминируемые союзы

Источник на Github: Демонстрация TypeScript

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

Вступление

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

Начиная

Хотя этот пост может быть самостоятельным, это один пост из серии. Если вы хотите начать с самого начала, посмотрите: Написание безопасного для типов (ish) кода JavaScript. Самым важным является то, как я настроил свой проект, чтобы вы могли следить за примерами кода.

Введите охранников с оператором «is».

$ git checkout type-guards

Защита типа - отличный инструмент для работы с типами объединения или ненадежными данными. Защита типа - это просто функция, которая проверяет, принадлежит ли ее аргумент к какому-либо типу.

interface IRectangle {
    width: number
    height: number
}
interface ICircle {
    radius: number
}
type Shape = IRectangle | ICircle
function isCicle(shape: Shape): shape is ICircle {
    return (shape as any).radius !== undefined
}

Интересным моментом здесь является определение функции. Его возвращаемый тип - что-то странное: «shape is ICircle». Эта функция буквально проверяет, имеет ли наш аргумент shape тип ICircle. Тип возвращаемого значения очень буквален в том, что он тестирует. Проще говоря, функция возвращает логическое значение. Однако мы говорим TypeScript, что если это логическое значение истинно, у нас есть ICircle.

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

const obj: Shape = { radius: 32 }
if (isCicle(obj)) {
    console.log(`Radius: ${obj.radius}`)
}

Когда защита типа возвращает true, TypeScript поймет и правильно проверит тип использование obj в качестве ICircle внутри блока if. Если вместо «obj.radius» мы попытаемся прочитать «obj.width», TypeScript выдаст ошибку времени компиляции.

Проверка недостоверных данных

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

Еще лучшее решение для проверки полезной нагрузки от API, которое возвращает «любой», - это фактически не использовать необработанные данные, полученные от этого API.

interface IResponse {
    status: number
    user: {
        id: number
        name: string
    }
}
function validatedResponse(rawResponse: any): IResponse {
    if (
        typeof rawResponse.status === 'number' &&
        typeof rawResponse.user === 'object' &&
        typeof rawResponse.user.id === 'number' &&
        typeof rawResponse.user.name === 'string'
    ) {
        return {
            status: rawResponse.status,
            user: {
                id: rawResponse.user.id,
                name: rawResponse.user.name,
            }
        }
    } else {
        throw new Error('Unknown response type')
    }
}

Почему я глубоко копирую объект, а не просто возвращаю rawResponse? Если я делаю это, потому что API потенциально ненадежен, возможно, могут быть дополнительные свойства, которые мне не нужны, что может вызвать проблемы позже, если я буду повторять этот объект. Лучше иметь самые надежные гарантии.

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

Дискриминационные союзы

$ git checkout discriminated-unions

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

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

interface IRectangle {
    type: 'Rectangle'
    width: number
    height: number
}
interface ICircle {
    type: 'Circle'
    radius: number
}
type Shape = IRectangle | ICircle

В TypeScript буквальные значения могут использоваться как типы. Тип ICircle должен иметь свойство «тип», и значение этого свойства должно быть буквальной строкой «Circle». Это свойство типа отличает каждый из типов в объединении Shape. Поскольку значение свойства типа уникально для каждого типа в объединении форм, TypeScript может различать типы объединения на основе проверки этого свойства.

function area(shape: Shape): number {
    switch (shape.type) {
        case 'Rectangle':
            return shape.width * shape.height
        case 'Circle':
            return Math.PI * Math.pow(shape.radius, 2)
        
    }
}

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

Использование Never

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

function area(shape: Shape): number {
    switch (shape.type) {
        case 'Rectangle':
            return shape.width * shape.height
        case 'Circle':
            return Math.PI * Math.pow(shape.radius, 2)
        default:
            const msg: never = shape
            throw new TypeError(`Unknown type: ${msg}`)
    }
}

Поскольку мы используем тип never в случае по умолчанию, TypeScript подтвердит, что выполнение варианта по умолчанию невозможно. Это гарантирует, что наши случаи являются исчерпывающими для типов в объединении Shape.

Чтобы проиллюстрировать ценность этого. Давайте добавим тип к нашему объединению Shape.

interface ITriangle {
    type: 'Triangle'
    side: number
}
type Shape = IRectangle | ICircle | ITriangle

Теперь, если мы попробуем скомпилировать наш код.

$ npm run build
Type 'ITriangle' is not assignable to type 'never'.

Компилятор сообщает нам, что именно нам не хватает. Нам нужно добавить случай для обработки ITriangle в нашу функцию площади.

function area(shape: Shape): number {
    switch (shape.type) {
        case 'Rectangle':
            return shape.width * shape.height
        case 'Circle':
            return Math.PI * Math.pow(shape.radius, 2)
        case 'Triangle':
            return (Math.sqrt(3)/4) * Math.pow(shape.side, 2)
        default:
            const msg: never = shape
            throw new TypeError(`Unknown type: ${msg}`)
    }
}

Теперь все снова компилируется.

Заключение

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

  1. Оператор типа «is», чтобы сообщить компилятору, что это за тип.
  2. Возможность использовать буквальные значения в качестве типов. Это относится к типам как к правилам, определяющим набор допустимых значений. В этом случае мы добавляем в набор буквальные значения.
  3. Способность TypeScript различать типы на основе уникальных свойств этих типов.
  4. Ключевое слово never для достижения полного соответствия типов.

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

Еще статьи из этой серии

Дальнейшее чтение