Pull to refresh
304.64
Конференции Олега Бунина (Онтико)
Конференции Олега Бунина

Продвинутый TypeScript

Reading time 12 min
Views 23K
Фридайвинг — ныряние на глубину без акваланга. Ныряльщик ощущает на себе закон Архимеда: он вытесняет некоторый объём воды, который выталкивает его обратно. Поэтому первые несколько метров даются тяжелее всего, но потом сила давления толщи воды над тобой начинает помогать двигаться глубже. Этот процесс напоминает изучение и погружение в системы типов TypeScript — по мере погружения становится немного легче. Но надо не забыть вовремя вынырнуть.


Фотография с сайта One Ocean One Breath.

Михаил Башуров (saitonakamura) — Senior Frontend Engineer в компании WiseBits, фанат TypeScript и фридайвер-любитель. Аналогии изучения TypeScript и ныряния на глубину не случайны. Михаил расскажет, что такое discriminated unions, как использовать вывод типов, зачем нужна номинальная совместимость и брендирование. Задержите дыхание и погружайтесь.

Презентация и ссылки на GitHub с кодом примеров здесь.

Тип произведения


Типы суммы звучит алгебраично — попробуем разобраться, что это такое. В ReasonML они называются variant, в Haskell — tagged union, а в F# и в TypeScript — discriminated union.

Википедия даёт такое определение: «Тип суммы — это сумма типов произведений».
— Спасибо капитан!

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

Предположим, что у нас есть тип Task. У него есть два поля: id и кто его создал.

type Task = { id: number, whoCreated: number }

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

type Task = { id: number } & { whoCreated: number }

В алгебре высказываний это называется логическим умножением: это операция «И» или «AND». Тип произведения — логическое произведение этих двух элементов.
Объект с набором полей можно выразить через логическое произведение.
Тип произведения не ограничен полями. Представим, что мы любим Prototype и решили, что нам не хватает leftPad.

type RichString = string & { leftPad: (toLength: number) => string }

Добавим его в String.prototype. Чтобы выразить в типах, возьмём string и функцию, которая принимает необходимые значения. Метод и string — это пересечение.

Объединение


Объединение — union — пригодится, например, для представления типа переменной, которая задаёт ширину элемента в CSS: строки в 10 пикселей или абсолютного значения. На самом деле спецификация CSS гораздо сложнее, но для простоты давайте оставим так.

type Width = string | number

Пример сложнее. Предположим, у нас есть задача. Она может находиться в трех состояниях: только что создана, принята в работу и завершена.

type InitialTask = { id: number, whoCreated: number }
type InWorkTask = { id: number, whoCreated: number }
type FinishTask = { id: number, whoCreated: number, finishDate: Date }

type Task = InitialTask | InWorkTask | FinishedTask

В первом и втором состояниях у задачи есть id и кто её создал, а в третьем появляется дата завершения. Тут и появляется объединение — либо то, либо это.

Примечание. Вместо type можно использовать interface. Но есть нюансы. Результат union и intersection — это всегда тип. Можно выразить intersection через extends, но union через interface нельзя. При этом type просто короче.

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

Совместимость типов


Но есть проблема — в TypeScript структурная совместимость типов. Это значит, что если у первого и второго типа одинаковый набор полей, то это два одинаковых типа. Например, у нас есть 10 типов с разными названиями, но с идентичной структурой (с одинаковыми полями). Если подадим любой из 10 типов в функцию, которая принимает один из них, TypeScript не будет возражать.

В Java или C# наоборот номинальная система совместимости. Напишем те же 10 классов с одинаковыми полями и функцию, которая принимает один из них. Функция не примет остальные классы, если они не прямые наследники типа, который предназначен для функции.

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

Для примера обратимся к ReasonML. Вот как этот тип выглядит там.

type Task =
| Initial({ id: int, whoCreated: int })
| InWork({ id: int, whoCreated: int })
| Finished({
        id: int,
        whoCreated: int,
        finshDate: Date
    })

Те же поля, кроме того, что int вместо number. Если приглядеться, заметим Initial, InWork и Finished. Они располагаются не слева, в названии типа, а справа в определении. Это не просто строчка в названии, а часть отдельного типа, поэтому мы можем отличить первый от второго.

Лайфхак. Для общих типов (например ваши доменные сущности) создайте файл global.d.ts и добавьте в него все типы. Они будут автоматически видны по всей кодовой базе TypeScript без необходимости явного импорта. Это удобно для миграции, потому что не надо беспокоиться о том, где поместить типы.

Рассмотрим, как это делать, на примере примитивного Redux-кода.

export const taskReducer = (state, action) = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, erro: action.payload }
    }
    
    return state
}

Перепишем код на TypeScript. Начнём с переименовывания файла — из .js в .ts. Включим все state-опции и опцию noImplicitAny. Эта опция находит функции, в которых не указан тип параметра, и полезна на этапе миграции.

Типизируем State: добавим поле isFetching, Task (которого может не быть) и Error.

type Task = { title: string }

declare module "*.jpg" {
    const url: string
    export default url
}

declare module "*.png" {
    const url: string
    export default url
}

Примечание. Лайфхак подсмотрел в книге Basarat Ali Syed «TypeScript Deep Dive». Она немного устарела, но всё ещё полезна для тех, кто хочет погрузиться в TypeScript. О современном состоянии TypeScript читайте в блоге Мариуса Шульца. Он пишет о новых фичах и приемах. Также изучайте change logs, там есть не только информация об обновлениях, но и то, как их использовать.

Остались actions. Затипизируем и объявим каждый из них.

type FetchAction = {
    type: "TASK_FETCH"
}

type SuccessAction = {
    type: "TASK_SUCCESS",
    payload: Task

type FailAction = {
    type: "TASK_FAIL",
    payload: Error
}

type Actions = FetchAction | SuccessAction | FailAction

Поле type у всех типов оно разное, это не просто string, а конкретная строка — строковый литерал. Это и есть тот самый дискриминатор — тег, по которому мы отличаем все кейсы. Получили discriminated union и, например, можем понять, что находится в payload.

Обновим изначальный Redux-код:

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
    }
    
    return state
}

Здесь есть опасность, если напишем string
type FetchAction = {
    type: string
}
… то все сломается, потому что это уже не дискриминатор.

В этом action, type может быть вообще любой строкой, а не конкретным дискриминатором. После этого TypeScript не сможет отличить один action от другого и найти ошибки. Поэтому здесь должен быть именно строковый литерал. Более того, мы можем добавить такую конструкцию: type ActionType = Actions["type"].

В подсказках будут всплывать три наших варианта.



Если же написать…
type FetchAction = {
    type: string
}
… то в подсказках будет просто string, потому что все остальные строки уже не важны.



Мы всё затипизировали и получили тип сумму.

Exhaustive checking


Представим гипотетическую ситуацию, когда в код не добавлена обработка ошибок.

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        //case "TASK_FAIL":
            //return { isFetching: false, error: action.payload }
    }
    
    return state
}

Здесь нам поможет exhaustive checking — ещё одно полезное свойство типа суммы. Это возможность проверить, что мы обработали все возможные кейсы.

Добавим переменную после switch: const exhaustiveCheck: never = action.

never — интересный тип:

  • если он находится в результате возврата функции, то функция никогда не завершается корректно (например всегда бросает ошибку или исполняется бесконечно);
  • если это тип, то этот тип нельзя создать без каких-то дополнительных усилий.

Теперь компилятор укажет на ошибку «Type 'FailAction' is not assignable to type 'never'». У нас есть три возможных типа actions, из которых мы не обработали "TASK_FAIL" — это и есть FailAction. Но к never он не «assignable».

Допишем обработку "TASK_FAIL", и ошибки больше не будет. Обработали "TASK_FETCH" — вернулись, обработали "TASK_SUCCESS" — вернулись, обработали "TASK_FAIL". Когда мы обработали все три функции, чем может быть action? Ничем — never.

Если добавить еще один action, компилятор подскажет, какие из них не обработаны. Это поможет, если хотите реагировать на все actions, а если только на выборочные, то нет.

Первый совет

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

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

Номинальная система типов


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

Например, у нас есть такой код функции.

export const githubUrl = process.env.GITHUB_URL as string
export const nodeEnv = process.env.NODE_ENV as string

export const fetchStarredRepos = (
    nodeEnv: string,
    githubUrl: string
): Promise<GithubRepo[ ]> => {
    if (nodeEnv = "production") {
        // log call
    }

    // prettier-ignore
    return fetch('$githubUrl}/users/saitonakamura/starred')
        .then(r => r.json());
}

Конфигурация приходит по API: GITHUB_URL=https://api.github.com. Метод githubUrl по API достает репозитории, на которые мы ставим «звездочку». А если nodeEnv = "production", то он логирует этот вызов, например, для метрик.

Для функции мы хотим разработать (UI).

import React, { useState } from "react"

export const StarredRepos = () => {
    const [starredRepos, setStarredRepos] = useState<GithubRepo[ ] | null>(null)

    if (!starredRepos) return <div>Loading…</div>

    return (
        <ul>
            {starredRepos.map(repo => (
                <li key={repo.name}>repo.name}</li>
            ))}
        </ul>
    )
}

type GithubRepo = {
    name: string
}

Функция уже умеет отображать данные, а если их нет, то лоадер. Осталось добавить API-вызов и засетить данные.

useEffect(() => {
    fetchStarredRepos(githubUrl, nodeEnv).then(data => setStarredRepos(data))
}, [ ])

Но если мы запустим это код — всё упадет. В панели разработчика обнаружим, что fetch обращается по адресу '/users/saitonakamura/starred' — githubUrl куда-то пропал. Оказывается, что все из-за странного дизайна — nodeEnv идет первым. Конечно, может появиться соблазн все поменять, но функция может использоваться в других местах кодовой базы.

Но что, если об этом заранее подскажет компилятор? Тогда не придётся проходить весь цикл запуска, обнаружения ошибки, поиска причин.

Брендирование


Для этого в TypeScript есть хак — бренд-типы. Создадим Brand, B (строка), T и два типа. Такой же тип создадим для NodeEnv.

type Brand<T, B extends string> = T & { readonly _brand: B }

type GithubUrl = Brand<string, "githubUrl">
export const githubUrl = process.env.GITHUB_URL as GithubUrl

type NodeEnv = Brand<string, "nodeEnv">
export const nodeEnv = process.env.NODE_ENV as NodeEnv

Но получим ошибку.



Теперь githubUrl и nodeEnv нельзя присвоить друг другу, потому что это номинальные типы. Неказистые, но номинальные. Теперь мы не можем их поменять местами здесь — меняем в другой части кода.

useEffect(() => {
    fetchStarresRepos(nodeEnv, githubUrl).then(data => setStarredRepos(data))
}, [ ])

Теперь всё хорошо — получили брендированные примитивы. Брендирование полезно, когда встречается несколько аргументов (строки, числа). У них есть определенная семантика (x,y координаты), и их нельзя путать. Удобно, когда компилятор подсказывает, что они перепутаны.

Но здесь есть две проблемы. Первая — в TypeScript нет нативных номинальных типов. Но есть надежда, что она решится, в репозитории языка идёт обсуждение этого вопроса.

Вторая проблема — «as», нет никаких гарантий, что в GITHUB_URL не битая ссылка.

Также, как и с NODE_ENV. Скорее всего, мы хотим не просто какую-то строку, а "production" или "development".

type NodeEnv = Brand<"production" | "development" | "nodeEnv">
export const nodeEnv = process.env.NODE_ENV as NodeEnv

Всё это нужно проверять. Отсылаю вас к смарт-конструкторам и докладу Сергея Черепанова «Проектирование предметной области на TypeScript в функциональном стиле».

Второй и третий советы

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

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

Что умеет TypeScript


TypeScript умеет выводить типы.

export const createFetch = () => ({
    type: "TASK_FETCH"
})

export const createSuccess = (task: Task) => ({
    type: "TASK_SUCCESS"
    payload: Task
})

export const createFail = (error: Error) => ({
    type: "TASK_FAIL"
    payload: error
})

type FetchAction = {
    type: "TASK_FETCH",

type SuccessAction = {
    type: "TASK_SUCCESS",
    payload: Task

type FailAction = {
    type: "TASK_FAIL",
    payload: Error
}

Для этого в TypeScript есть ReturnType — он достает из функции возвращаемое значение:
type FetchAction = ReturnType<typeof createFetch>
В нём мы передаём тип функции. Просто функцию записать мы не можем: чтобы взять тип от функции или переменной, нужно написать typeof.

В подсказках видим type: string.



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

export const createFetch = () => ({
    type: "TASK_FETCH"
})

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

Нам нужно как-то помочь TypeScript. Для этого есть as const.

export const createFetch = ( ) => ({
    type: "TASK_FETCH" as const
})

Добавим — в подсказках сразу исчезнет string. Мы можем написать это не только напротив строки, а вообще всего литерала.

export const createFetch = ( ) => ({
    type: "TASK_FETCH"
} as const)

Тогда тип (и все поля) станут readonly.

type FetchAction = {
    readonly type: "TASK_FETCH";
}

Это полезно, потому что вряд ли вы будете менять мутабельность своего action. Поэтому добавим as const везде.

export const createFetch = () => ({
    type: "TASK_FETCH"
} as const)

export const createSuccess = (task: Task) => ({
    type: "TASK_SUCCESS"
    payload: Task
} as const)

export const createFail = (error: Error) => ({
    type: "TASK_FAIL"
    payload: error
} as const)

type Actions =
    | ReturnType<typeof createFetch>
    | ReturnType<typeof createSuccess>
    | ReturnType<typeof createFail>

type State =
    | { isFetching: true }
    | { isFetching: false; task: Task }
    | { isFetching: false; error: Error }

export const taskReducer = (state: State, action: Actions): State = > {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

  const _exhaustiveCheck: never = action

  return state
}

Весь код типизации actions сократили и добавили as const. Все остальное TypeScript понял сам.

TypeScript умеет выводить State. Он представлен объединением в коде выше тремя возможными состояниями isFetching: true, false или Task.

Используем type State = ReturnType. В подсказках TypeScript укажет, что есть циклическая зависимость.



Сокращаем.

type State = ReturnType<typeof taskReducer>

export const taskReducer = (state, action: Actions) => {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

const _exhaustiveCheck: never = action

return state
}

State перестал ругаться, но теперь он any, потому что у нас циклическая зависимость. Типизируем аргумент.

type State = ReturnType<typeof taskReducer>

export const taskReducer = (state: { isFetching: true }, action: Actions) => {
    switch (action.type) {
        case "TASK_FETCH":
            return { isFetching: true }
        case "TASK_SUCCESS":
            return { isFetching: false, task: action.payload }
        case "TASK_FAIL":
            return { isFetching: false, error: action.payload }
}

const _exhaustiveCheck: never = action

return state
}

Вывод готов.



Вывод похож на то, что у нас было изначально: true, false, Task. Здесь есть мусорные поля Error, но с типом undefined — поле вроде есть, а вроде и нет.

Четвертый совет

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

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

Насколько TypeScript замедляет разработку


Какое-то время понадобится на поддержку типизации. У нее не самый лучший UX — иногда она выдает совершенно непонятные ошибки, как и любые другие системы типов, как во Flow или Haskell.
Чем выразительнее система, тем сложнее ошибки.
Ценность системы типов в том, что она выдаёт быструю обратную связь при возникновении ошибок. Система покажет ошибки, и уйдет меньше времени на их поиск и исправление. Если тратить меньше времени на исправление ошибок, то больше внимания получат архитектурные решения. Типы не замедляют разработку, если научиться с ними работать.

Снова скоро будем погружаться в новые концепции и нырять поглубже в известные технологии на РИТ++. Обновленный онлайн-фестиваль для тех, кто делает Интернет, будет состоять из серии расширяющих кругозор докладов (25 и 26 мая) и мастер-классов для глубокого разбора практических задач (27 мая — 10 июня).

Цены на билеты снижены настолько (5900 для физлица), что РИТ++ стал не только максимально удобным и быстрым способом узнать срез всей IT-индустрии, но и очень выгодным.

А еще при поддержке AvitoTech видеозаписи FrontendConf 2019 теперь в открытом доступе. Смотрите их на youtube-канале и подписывайтесь на рассылку или telegram @FrontendConfChannel, чтобы в будущем не пропустить такие новости.
Tags:
Hubs:
+27
Comments 3
Comments Comments 3

Articles

Information

Website
www.ontico.ru
Registered
Founded
Employees
11–30 employees
Location
Россия