TypeScript. Мощь never

в 16:15, , рубрики: never, TypeScript

Когда я впервые увидел слово never, то подумал, насколько бесполезный тип появился в TypeScript. Со временем, все глубже погружаясь в ts, стал понимать, какой мощью обладает это слово. А эта мощь рождается из реальных примеров использования, которыми я намерен поделиться с читателем. Кому интересно, добро пожаловать под кат.

Что такое never

Если окунуться в историю, то мы увидим, что тип never появился на заре TypeScript версии 2.0, с достаточно скромным описанием его предназначения. Если кратко и вольно пересказать версию разработчиков ts, то тип never — это примитивный тип, который олицетворяет собой признак для значений, которых никогда не будет. Или, признак для функций, которые никогда не вернут значения, то ли по причине ее зацикленности, например, бесконечный цикл, то ли по причине ее прерывания. И чтобы наглядно показать суть сказанного, предлагаю посмотреть пример ниже

/** Пример с прерыванием */
function error(message: string): never {
    throw new Error(message);
}

/** Бесконечный цикл */
function infiniteLoop(): never {
    while (true) {
    }
}

/** Божественная рекурсия */
function infiniteRec(): never {
    return infiniteRec();
}

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

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

Сейчас я могу утверждать, что богатая фауна системы типов в TypeScript в том числе — это заслуга never. И в подтверждение своих слов приведу несколько библиотечных типов из lib.es5.d.ts

/** Exclude from T those types that are assignable to U */
type Exclude<T, U> = T extends U ? never : T;

/** Extract from T those types that are assignable to U */
type Extract<T, U> = T extends U ? T : never;

/** Construct a type with the properties of T except for those in type K. */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

/** Exclude null and undefined from T */
type NonNullable<T> = T extends null | undefined ? never : T;

/** Obtain the parameters of a function type in a tuple */
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

Из своих типов с never приведу любимый — GetNames, усовершенствованный аналог keyof:

/**
 * GetNames тип для извлечения набора ключей
 * @template FromType тип - источник ключей
 * @template KeepType критерий фильтрации
 * @template Include  признак для указания как интерпретировать критерий фильтрации. В случае false - инвертировать результат для KeepType  
 */
type GetNames<FromType, KeepType = any, Include = true> = {
    [K in keyof FromType]: 
        FromType[K] extends KeepType ? 
            Include extends true ? K : 
            never : Include extends true ? 
            never : K
}[keyof FromType];

// Пример использования
class SomeClass {

    firstName: string;
    lastName: string;
    age: number;
    count: number;

    getData(): string {
        return "dummy";
    }
}

// be: "firstName" | "lastName"
type StringKeys = GetNames<SomeClass, string>;

// be: "age" | "count"
type NumberKeys = GetNames<SomeClass, number>;

// be: "getData"
type FunctionKeys = GetNames<SomeClass, Function>;

// be: "firstName" | "lastName" | "age" | "count"
type NonFunctionKeys = GetNames<SomeClass, Function, false>;

Контроль будущих изменений

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

// Некий набор действий - может жить далеко, даже не в том проекте, что и ActionEngine
type AdminAction = "CREATE" | "ACTIVATE";

// Место, где этот набор действий обрабатывается, хотя тип AdminAction определен неизвестно где. 
class ActionEngine {
    doAction(action: AdminAction) {
        switch (action) {
            case "CREATE":
                // логика здесь
                return "CREATED";
            case "ACTIVATE":
                // логика здесь
                return "ACTIVATED";
            default:
                throw new Error("Этого не должно случиться");
        }
    }
}

Код выше упрощен настолько, насколько это возможно только для того, чтобы акцентировать внимание на важном моменте — тип AdminAction определен в другом проекте и даже возможно, что сопровождается не Вашей командой. Так как проект будет жить долгое время, необходимо уберечь свой ActionEngine от изменений в типе AdminAction без Вашего ведома. TypeScript для решения такой задачи предлагает несколько рецептов, один из которых — использовать тип never. Для этого нам потребуется определить NeverError и использовать его в методе doAction.

class NeverError extends Error {
    // если дело дойдет до вызова конструктора с параметром - ts выдаст ошибку
    constructor(value: never) {
        super(`Unreachable statement: ${value}`);
    }
}

class ActionEngine {
    doAction(action: AdminAction) {
        switch (action) {
            case "CREATE":
                // логика здесь
                return "CREATED";
            case "ACTIVATE":
                // логика здесь
                return "ACTIVATED";
            default:
                throw new NeverError(action);
                //                   ^ контролирует здесь что все варианты в switch блоке определены.
        }
    }
}

Теперь добавьте в AdminAction новое значение «BLOCK» и получите ошибку на этапе компиляции: Argument of type '«BLOCK»' is not assignable to parameter of type 'never'.ts(2345).
В принципе мы этого и добивались. Стоит упомянуть интересный момент, что от изменения элементов AdminAction или удаления из набора нас защищает конструкция switch. Из практики использования могу сказать, что это действительно работает так как ожидается.
Если нет желания вводить класс NeverError, то можно контролировать код через объявление переменной с типом never. Вот так

type AdminAction = "CREATE" | "ACTIVATE" | "BLOCK";
class ActionEngine {
    doAction(action: AdminAction) {
        switch (action) {
            case "CREATE":
                // логика здесь
                return "CREATED";
            case "ACTIVATE":
                // логика здесь
                return "ACTIVATED";
            default:
                const unknownAction: never = action; // Type '"BLOCK"' is not assignable to type 'never'.ts(2322)
                throw new Error(`Неизвестный тип действия ${unknownAction}`);
        }
    }
}

Ограничение контекста: this + never

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

@SomeDecorator({...})
class SomeUiPanel {

    @Inject
    private someService: SomeService;

    public beforeAccessHook() {
        // Не смотря на то, что метод не статический, ему недоступны поля класса SomeUiPanel
        this.someService.doInit("Bla bla");
        //  ^ приведет к ошибки на этапе выполнения кода: метод beforeAccessHook вызывается в контексте, делающий доступ к сервису невозможным
    }
}

В более широком кейсе это могут быть callback или стрелочные функции, имеющие свои контексты выполнения. И задача звучит так: Как защитить себя от ошибки времени выполнения? Для этого в TypeScript есть возможность указать контекст this

@SomeDecorator({...})
class SomeUiPanel {

    @Inject
    private someService: SomeService;

    public beforeAccessHook(this: never) {
        // Не смотря на то, что метод не статический, ему недоступны поля класса SomeUiPanel
        this.someService.doInit("Bla bla");
        //  ^ Property 'someService' does not exist on type 'never'
    }
}

Справедливости ради, скажу, что это не заслуга never. Вместо него можно использовать и void и {}. Но внимание привлекает именно тип never, когда читаешь код.

Ожидания

Инварианты

Имея определенное представление о never, я думал, что следующий код должен заработать:

type Maybe<T> = T | void;

function invariant<Cond extends boolean>(condition: Cond, message: string): Cond extends true ? void : never {
    if (condition) {
        return;
    }
    throw new Error(message);
}

function f(x: Maybe<number>, c: number) {
    if (c > 0) {
        invariant(typeof x === "number", "When c is positive, x should be number");

        (x + 1); // works because x has been refined to "number"
    }
}

Но увы. Выражение (x + 1) выдает ошибку: Operator '+' cannot be applied to types 'Maybe' and '1'. Сам пример я подсмотрел в статье Переносим 30 000 строк кода с Flow на TypeScript.

Гибкая обязательность

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

function variants<Type extends number | string>(x: Type, c: Type extends number ? number : never): number {
    if (typeof x === "number") {
        return x + c;
    }
    return +x;
}

const three = variants(1, 2); // ok
// 2 аргумент - never, если первый с типом string. Увы, обязательность сохраняется
const one = variants("1"); // expected 2 arguments, but got 1.ts(2554)

Выше указанная задача решается другим способом

Более строгая проверка

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

variants(<never> {}, <never> {});

Заключение

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

class never<never> {
    never: never;
}

const whats = new never<string>();
whats.never = "";

Вариант

В последней: Type '""' is not assignable to type 'never'.ts(2322)

Это все, что я хотел рассказать про never. Всем спасибо за внимание и до новых встреч.

Автор: reforms

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js