TypeScript. Мощь never

    Когда я впервые увидел слово 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. Всем спасибо за внимание и до новых встреч.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 15

      +2

      Функцию invariant можно сделать в TypeScript 3.7(сейчас бета) — https://tinyurl.com/y2lonrgk
      Там добавили классную фичу "Assertion Functions"
      https://github.com/microsoft/TypeScript/pull/32695

        0

        О, круть будем ждать релиза, в ФП кстаки есть функция которая выполняет роль NeverError называется absurd: <A>(x: never) => A. Вот мой вариант этого ts хелпера

        0
        public beforeAccessHook(this: never)

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


        В таких случаях следует использовать null.


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

        Та же самая ошибка: never не является отсутствием параметра, это отсутствие возможных значений параметра.

          0
          это всё хорошо конечно, но если есть кто-то кто только что понял всю суть тс… объясните пожалуйста.
          Вопрос короткий: «зачем писать лишний код, с заведомо явным поведением?»
          Каждый раз когда спрашиваю тайпскриптеров про такого вида «театр безопасности», слышу вот ответ одни отмашки =/
            +3
            Вопрос короткий: «зачем писать лишний код, с заведомо явным поведением?»

            Незачем, никто и не пишет. При чем тут тс?

              +4
              Вопрос явно провокационный, но я попробую ответить.
              1. Это как ремень безопасности. Нужно совершать «лишние» действия, каждый раз в надежде что это хоть раз пригодится. Кто-то использует, а кто-то нет. Но статистика — твердолобая штука, и говорит что те кто с ремнём — выживают. И далее, когда вы знаете что в безопасности, то можете двигаться быстрее. А по тропе у обрыва вы будете двигаться гораздо медленнее и более осторожно, тратить дополнительные нервы на это.

              2. Про заведомо явное поведение. Одно дело явное поведение, а другое — явная декларация этого поведения в интерфейсе, и уверенность в строгом соблюдении этой декларации. На маленьком проекте это кажется ненужным и бесполезным. А на большом (например в моём тайпскрипт проекте сейчас 3500 файлов и пол миллиона строк кода, публиковал статистику тут habr.com/ru/company/directum/blog/462055/#comment_20464837) вам важно бегло ориентироваться по интерфейсам. На большом проекте никто и никогда не может знать как работает весь код. С ростом кодовой базы затраты на чтение и понимание кода вокруг всё растут и превышают затраты на написание дополнительных деклараций. И, нужно сказать, что написание интерфейсов и деклараций в реальности не так уж и много времени отнимает, и лично для меня, за счёт корректной работы подсказчика, кратно повышает скорость написания кода. И последнее про скорость, на самом деле скорость набора кода почти ни на что не влияет. Основное время уходит на чтение кода, архитектуру, дебаг, обслуживание этого кода, обучение коллег и т.п. Именно сам фигачинг и потоковый набор кода — не такой уж и большой процент времени программиста занимает.

              И кстати, отдельно про тайпскрипт и безопасность. Он легко позволяет безопасность нарушать там где этого хочется. И это прекрасно. Всё не абсолютно строго, и если ты понимаешь что ты хочешь, то ты можешь лавировать в этих джунглях ограничений порой выруливая абсолютно сложные и неразрешимые проблемы в более строгих языках. Я бы назвал это строгостью по запросу. Мне нужна безопасность, инструмент даёт её мне. Я хочу выстрелить между пальцев ноги, и я элементарно снимаю защиту от стрельбы в ногу и стреляю. Если я всё правильно посчитал, то выстрел меж пальцев прекрасно удаётся, если нет — мне оторвёт ногу. Я откачу ревизию и подумаю получше. Этот подход как обоюдоострый меч требует ответственности и вкуса от пользователя, но открывает возможности возлагая ответственность.
                0
                И ещё, если вы не замечаете, то тайпскрипт идёт по пути не только создания инструментов безопасного написания кода, но и так чтобы инструменты безопасности не были навязчивыми. Автовыведение типов, flow analysis и многое другое ведёт к тому что код становится более чистым но столь же типизированным. Они планомерно уходят от необходимости лишний раз описывать те типы который и так можно вывести, и надо сказать что уже очень сильно продвинулись.
                0
                Я не знаком с TypeScript, но тема крайне интересная.
                Смысл типа never вполне понятен — в некоторых языках есть что-то типа «noreturn» (тип или спецификатор), это используется оптимизаторами.
                Но вот непонятно, чем тип never в системе типов и при использовании в качестве параметра шаблонов лучше чем какой нибудь void? Т.е. ясно что это разные вещи, но вот как именно never влияет на шаблонный код?
                  +1

                  void может иметь значение undefined (и, строго говоря, больше ничего), то есть просто означает, что вычисления дошли до соответствующей строчки кода. never значения иметь не может вообще — т.е. обозначает, напротив, некоторый недостижимый код.

                    0

                    Уточнение: в Typescript void может принимать два значения, undefined и null.

                      0
                      Ну я спрашивал не про typescript, а вообще.
                      А то что void может принимать два значения это уже какой-то нонсенс:) (хотя наверное понятно почему так, но все-же это из разряда языковых курьезов, которые во всех языках программирования бывают)
                        0
                        Уточнение: в Typescript void может принимать два значения, undefined и null.

                        null может только без strictNullCheck'а, но без него абсолютно все типы nullable, т.е. по факту void это только undefined.


                        И даже для undefined к этому утверждению надо относиться с осторожностью — хотя undefined assignable to void, тип undefined не является подтипом типа void.

                    0
                    после заключения должна быть картинка с Риком Эстли
                      0
                      Благодарю за работу, как раз сегодня пригодился прием из статьи.
                      Вряд ли я бы его использовал до нее :)
                        0
                        Всегда пожалуйста :)

                        Какой прием, если не секрет?

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

                      Самое читаемое