TypeScript. Магия выражений

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

    Немного правды


    Правда N1.

    Большинство конструкций и неожиданных находок, изложенных ниже, мне впервые попались на глаза на страницах Stack Overflow, github или вовсе были изобретены самостоятельно. И только потом дошло — это все есть здесь или здесь. Поэтому заранее прошу отнестись с пониманием, если изложенные находки Вам покажутся банальными.

    Правда N2.

    Практическая ценность некоторых конструкций равна 0.

    Правда N3.

    Примеры проверялись под tsc версии 3.4.5 и целевой es5. На всякий случай под спойлером конфиг

    tsconfig.json
    {
    «compilerOptions»: {
    «outFile»: "./target/result.js",
    «module»: «amd»,
    «target»: «es5»,
    «declaration»: true,
    «noImplicitAny»: true,
    «noImplicitReturns»: true,
    «strictNullChecks»: true,
    «strictPropertyInitialization»: true,
    «experimentalDecorators»: true,
    «emitDecoratorMetadata»: true,
    «preserveConstEnums»: true,
    «noResolve»: true,
    «sourceMap»: true,
    «inlineSources»: true
    },
    «include»: [
    "./src"
    ]
    }


    Реализация и наследование


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

        abstract class ClassA {
            abstract getA(): string;
        }
    
        abstract class ClassB {
            abstract getB(): string;
        }
    
        // Да, tsc нормально на это реагирует
        abstract class ClassC implements ClassA, ClassB {
            //                ^ обратите внимание, на использование implements с классами.
            abstract getA(): string;
    
            abstract getB(): string;
        }
    

    Думаю разработчики TypeScript позаботились о 'строгих контрактах', исполненных через ключевое слово class. Причем классы необязательно должны быть абстрактными.

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

    class One {
        one = "__one__";
        getOne(): string {
            return "one";
        }
    }
    
    class Two {
        two = "__two__";
        getTwo(): string {
            return "two";
        }
    }
    
    // Теже миксины, но в удобном виде: Подсказки в IDE (кроме статических полей) и автокомплит как положено.
    class BothTogether extends mix(One, Two) {
        //                     ^ находка в том, что в части extends допускаются выражения
        info(): string {
            return "BothTogether: " + this.getOne() + " and " + this.getTwo() + ", one: " + this.one + ", two: " + this.two;
            //                             ^ подсказки от IDE здесь и ^ имеется
        }
    }
    
    type FaceType<T> = {
        [K in keyof T]: T[K];
    };
    
    type Constructor<T> = {
        // prototype: T & {[key: string]: any};
        new(): T;
    };
    
    // TODO: эта реализация на коленке, можно не глядеть. Классная реализация есть на просторах интернета
    function mix<O, T, Mix = O & T>(o: Constructor<O>, t: Constructor<T>): FaceType<Mix> & Constructor<Mix> {
        function MixinClass(...args: any) {
            o.apply(this, args);
            t.apply(this, args);
        }
        const ignoreNamesFilter = (name: string) => ["constructor"].indexOf(name) === -1;
        [o, t].forEach(baseCtor => {
            Object.getOwnPropertyNames(baseCtor.prototype).filter(ignoreNamesFilter).forEach(name => {
                MixinClass.prototype[name] = baseCtor.prototype[name];
            });
        });
        return MixinClass as any;
    }
    
    const bt = new BothTogether();
    window.console.log(bt.info()); // >> BothTogether: one and two, one: __one__, two: __two__
    

    Находка: глубокий и в тоже время бессмысленный аноним.

    const Сlass = class extends class extends class extends class extends class {} {} {} {} {};
    

    А кто больше напишет слово класс с 4 extends в примере выше?

    Если так
    // tslint:disable
    const Class = class Class extends class Class extends class Class extends class Class extends class Class {} {} {} {} {};
    

    А еще больше?

    Вот так
    // tslint:disable
    const сlass = class Class<Class> extends class Class extends class Class extends class Class extends class Class {} {} {} {} {};
    

    Ну Вы поняли — просто класс!

    Восклицательный знак — безграничный оператор и модификатор



    Если Вы не используете настройки компиляции strictNullChecks и strictPropertyInitialization,
    то скорее всего знания о восклицательном знаке прошли рядом с Вами… Помимо основного предназначения, для него отведены еще 2 роли.

    Находка: Восклицательный знак в роли Non-null assertion operator

    Этот оператор позволяет обращаться к полю структуры, которое может быть null без проверки на null. Пример с пояснением:

     // Для проверки включаем режим --strictNullChecks
     type OptType = {
         maybe?: {
             data: string;
         };
     };
     // ...
     function process(optType: OptType) {
         completeOptFields(optType);
         // Мы знаем наверняка, что метод completeOptFields заполнит все необязательные поля.
         window.console.log(optType.maybe!.data);
         //                              ^ - берем на себя ответственность, что здесь не null
         //                                  если уберем !, то получим от tsc: Object is possibly 'undefined'
     }
    
     function completeOptFields(optType: OptType) {
         if (!optType.maybe) {
             optType.maybe = {
                 data: "some default info"
             };
         }
     }
    

    Итого, этот оператор позволяет убрать лишние проверки на null в коде, если мы уверены…

    Находка: Восклицательный знак в роли Definite assignment assertion modifier

    Этот модификатор позволит нам проинициализировать свойство класса потом, где-то в коде, при включенной опции компиляции strictPropertyInitialization. Пример с пояснением:

    // Для проверки включаем режим --strictPropertyInitialization
    class Field {
        foo!: number;
        // ^
        // Notice this '!' modifier.
        // This is the "definite assignment assertion"
    
        constructor() {
            this.initialize();
        }
    
        initialize() {
            this.foo = 0;
            //       ^ инициализация здесь
        }
    }
    

    Но вся эта мини выкладка про восклицательный знак не имела бы смысла без минутки юмора.

    Вопрос: Как вы думаете, скомпилируется ли следующее выражение?

    // Для проверки включаем режим --strictNullChecks
    type OptType = {
        maybe?: {
            data: string;
        };
    };
    function process(optType: OptType) {
        if (!!!optType.maybe!!!) {
            window.console.log("Just for fun");
        }
        window.console.log(optType.maybe!!!!.data);
    }
    

    Ответ
    Да

    Типы



    Каждый, кто пишет сложные типы открывает для себя много интересного. Вот и мне повезло.

    Находка: на подтип можно ссылаться по имени поля основного типа.

    type Person = {
        id: string;
        name: string;
        address: {
            city: string;
            street: string;
            house: string;
        }
    };
    
    type Address = Person["address"];
    

    Когда Вы пишите типы сами, такой подход объявления навряд ли имеет смысл. Но бывает так, что тип приходит из внешней библиотеки, а подтип — нет.

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

    class BaseDialog<In, Out> {
    
        show(params: In): Out {/** базовый код. В конце return ... */ }
    }
    
    // Декларация по-старинке
    class PersonDialogOld extends BaseDialog<Person[], string> {/** код здесь */}
    
    // Повышаем читаемость
    class PersonDialog extends BaseDialog<Person[], Person["id"]> {/** код здесь */}
    

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

    class SimpleBuilder {
    
        private constructor() {}
    
        static create(): SimpleBuilder {
            return new SimpleBuilder();
        }
    
        firstName(firstName: string): this {
            return this;
        }
    
        lastName(lastName: string): this {
            return this;
        }
    
        middleName(midleName: string): this {
            return this;
        }
    
        build(): string {
            return "what you needs";
        }
    }
    
    const builder = SimpleBuilder.create();
    // Так мы получаем требуемый объект.
    const result = builder.firstName("F").lastName("L").middleName("M").build();
    

    Пока не смотрите на избыточный метод create, приватный конструктор и вообще на использование этого шаблона в ts. Сосредоточиться нужно на цепочке вызовов. Идея в том, что вызываемые методы должны быть использованы строго 1 раз. Причем Ваша IDE также должна знать об этом. Другими словами после вызова любого метода у экземпляра builder этот метод должен исключаться из списка доступных. Достичь такой функциональности нам поможет тип NarrowCallside.

    type ExcludeMethod<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
    
    type NarrowCallside<T> = {
        [P in keyof T]: T[P] extends (...args: any) => T ?
            ReturnType<T[P]> extends T ?
            (...args: Parameters<T[P]>) => NarrowCallside<ExcludeMethod<T, P>>
            : T[P]
            : T[P];
    };
    
    class SimpleBuilder {
    
        private constructor() {}
    
        static create(): NarrowCallside<SimpleBuilder> {
            return new SimpleBuilder();
        }
    
        firstName(firstName: string): this {
            return this;
        }
    
        lastName(lastName: string): this {
            return this;
        }
    
        middleName(midleName: string): this {
            return this;
        }
    
        build(): string {
            return "what you needs";
        }
    }
    
    const builder = SimpleBuilder.create();
    const result = builder.firstName("F")
    //                    ^ - доступны все методы
                    .lastName("L")
    //              ^ - здесь доступны lastName, middleName и build
                .middleName("M")
    //          ^ - здесь доступны middleName и build
            .build();
    //      ^ - здесь доступен только build
    

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

    type FilterKeys<T> = ({[P in keyof T]: T[P] extends (...args: any) => any ? ReturnType<T[P]> extends never ? never : P : never })[keyof T];
    type FilterMethods<T> = Pick<T, FilterKeys<T>>;
    
    type BaseDirectCallside<T, Direct extends any[]> = FilterMethods<{
        [Key in keyof T]: T[Key] extends ((...args: any) => T) ?
            ((..._: Direct) => any) extends ((_: infer First, ..._1: infer Next) => any) ?
            First extends Key ?
            (...args: Parameters<T[Key]>) => BaseDirectCallside<T, Next>
        : never
        : never
        : T[Key]
    }>;
    
    type DirectCallside<T, P extends Array<keyof T>> = BaseDirectCallside<T, P>;
    
    class StrongBuilder {
    
        private constructor() {}
    
        static create(): DirectCallside<StrongBuilder, ["firstName", "lastName", "middleName"]> {
            return new StrongBuilder() as any;
        }
    
        firstName(firstName: string): this {
            return this;
        }
    
        lastName(lastName: string): this {
            return this;
        }
    
        middleName(midleName: string): this {
            return this;
        }
    
        build(): string {
            return "what you needs";
        }
    }
    
    const sBuilder = StrongBuilder.create();
    const sResult = sBuilder.firstName("F")
    //                      ^ - доступны только firstName и build
                        .lastName("L")
    //                  ^ - доступны только lastName и build
                    .middleName("M")
    //              ^ - доступны только middleName и build
                .build();
    //          ^ - доступен только build
    

    Итого



    Это все мои интересные находки по TypeScript на сегодня. Всем спасибо за внимание и до новых встреч.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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

      +10
      Выглядит как магия. Порой мне кажется, что я могу написать у себя в резюме, что я знаю Тайпскрипт, но после такого желание отпадает
        +1

        С одной стороны тайпскрипт является хорошей попыткой типизировать JS, закрывая один пласт проблем, но с другой стороны вносит другой пласт проблем поверх и так проблемного JS. Смотря на подобные вещи и на ошибки в багтрекере, понимаешь что это хождение по тонкому льду. Может это только моё субъективное мнение, но ощущается это именно так.

        +1
        Выглядит как магия.

        У нас по этому поводу в компании ходит шутка: 'В коде не должно быть никакой магии, а волшебство — пожалуйста'
          +1

          Автору поста тогда вопрос, как нормально реализовать типы, когда приналичии одного из передаваемых пропсов можно использовать и другие. Например мы хотим давать возможность устанавливать цвет рамок, только если в передаваемых пропсах так же указано, что мы вообще хотим использовать рамку. Самому ответ кажется очевидным, да и он есть в примерах, но при большом количестве наследуемых типов теряется подсветка в ide, а значит толку от таких типов не много.

            0

            Я не автор, но попробую ответить.


            Насколько я понимаю, TS не работает с отдельными частями интерфейса, а лишь "дискриминирует" состояние целиком. (Изначально union это как бы квантовое состояние, которое коллапсирует с каждым новым ограничением, но лишь целиком, пока не остается статический тип)


            Таким образом, сделать это можно юнионом как в примерах, но это приводит к комбинаторному взрыву состояний… (у IDE обычно ограничения глубины для комфорта работы, а компиляция просто будет все дольше и дольше)
            И того нам остается:


            1. Использовать под интерфейсы. Меняя структуру данных.
            2. Делать юнионы, при разумных количествах комбинаций.
            3. Забить.
            0
            Находка: Восклицательный знак в роли Non-null assertion operator

            Это вредный совет, рано или поздно вы по невнимательности наткнетесь на проверку number или string таким образом и получите баг. Лучше запретить использование небулевого выражения в булевом контексте с помощью tslint.
              +2

              Этот оператор не имеет никакого отношения к приведению к boolean. Этот оператор убирает null из типа.

                0
                невнимательно прочитал, оказывается речь шла не про assertion, а про подавление warning optType.maybe!.data
                +1
                + к статье
                На тайпскрипте можно написать ограниченный итератор (на 40 шагов), что позволяет делать некоторые крутые штуки
                github.com/pirix-gh/ts-toolbelt/blob/master/src/Tuple/Drop.ts
                как пример
                Кажется это можно использовать для такой задачи
                [{key: 'a', value: 'b'}, {key: 'b', value: 'c'}] в {a: 'b', b: 'c'}
                  0

                  Это ирония или я чего-то не понимаю? Чем плох reduce?


                  [{key: 'a', value: 'b'}, {key: 'b', value: 'c'}].reduce((acc, elem) => ({ ...acc, [elem.key]: elem.value }), {})
                    0
                    Я про конвертацию типов а не реальных значений
                      0
                      type arr = [{key: 'a', value: 'b'}, {key: 'b', value: 'c'}];
                      type obj = {
                          [K in arr[number]['key']]: Extract<arr[number], {key: K}>['value']
                      };
                      const obj: obj = {a: 'b', b: 'c'} // ошибки нет
                      const wrong1: obj = {}; // не хватает параметров
                      const wrong2: obj = {a: 'b', b: 'c', c: 'd'} // параметр c лишний
                      const wrong3: obj = {a: 'b', b: 1} // параметр b не того типа

                      Что я делаю не так?


                      upd: хабр, запили подсветку TS ><

                        0
                        Блин, прикольно, даже не думал об таком подходе
                    0
                    За ссылку спасибо, в какой-то мере там кладезь знаний по типа ts
                      0

                      С другой стороны, там очень сильно перемудрили. Например, вместо типа true ввели тип True=1. Аналогичные обёртки запилили над всеми типами. Или вместо двух обобщённых типов понавтыкали один с флагом. Или вкорячили арифметику для чисел от -40 до 40. Короче, как источник идей как типизировать что-то хитрое типа Compose использовать можно, но тянуть в проект такое я бы поостерёгся.

                      0
                      Кажется это можно использовать для такой задачи

                      Если Вы говорили конкретно про Drop, а не про библиотеку, то это не совсем верно. Drop нужен для исключения первых элементов кортежа.


                      type List = [number, string, boolean, undefined];
                      type DroppedList1 = Drop<MyList, 1>; // [string, boolean, undefined]
                      type DroppedList2 = Drop<MyList, 2>; // [boolean, undefined]

                      https://www.freecodecamp.org/news/typescript-curry-ramda-types-f747e99744ab/


                      В этой статье есть много про паттерны для работы с кортежами.

                        0
                        Не, я в целом имел ввиду подход к итерации на уровне типов
                      0
                      Магия TS мне очень нравится. Однако разбираться и разбираться в ней.
                      Например, попробовал сделать маппер, с полной поддержкой TS
                      interface MyMapper<F, T, TK extends keyof T> {
                          field: keyof F;
                          map: IMapScheme<F, T[TK]>; 
                      }
                      
                      type IMapScheme<F, T> = {
                          [TK in keyof T]: keyof F | MyMapper<F, T, TK>;
                      };
                      
                      interface From {
                          _id: string;
                          name: string;
                          data: {
                              foo: string;
                              bar: string;
                          };
                      }
                      
                      interface To {
                          id: string;
                          name: string;
                          meta: {
                              fooData: string;
                          };
                      }
                      
                      const Try1Scheme: IMapScheme<From, To> = {
                          id  : '_id',
                          name: 'name',
                          meta: {
                              field: 'data',
                              map  : {
                                  fooData: 'foo',
                              },
                          },
                      };


                      И очень просится написать что-то на подобии
                      interface MyMapper<F, T, TK extends keyof T> {
                          field: keyof F;
                          map: IMapScheme<F[this.field], T[TK]>; 
                      }


                      Такая магия возможна?
                        0

                        Не очень понял зачем вам эти типы. Просто напишите функцию-маппер и возльмите от неё typeof.

                          0
                          Я хочу создать объект, который описывает как должно быть замаплено.
                          Объект именно потому что несколько таких объектов можно потом склеить, в специфике этого приложения это крутая фича.
                          И мне интересно, может ли его валидность проверить сам TS.
                          А про функцию — задача эта уже решена, но хотелось бы ее решить красиво.
                            0

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

                          0
                          Задачка интересная. Сходу мне решить ее не удалось. Подумаю на досуге.
                          0
                          Cистема типов TypeScript явялется Тьюринг-полной github.com/Microsoft/TypeScript/issues/14833

                          Впрочем, не только у TypeScript, но и у Java habr.com/ru/post/330724, и С++ и др.

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

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