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 на сегодня. Всем спасибо за внимание и до новых встреч.

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


  1. inoyakaigor
    28.08.2019 14:53
    +9

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


    1. proninyaroslav
      28.08.2019 23:53
      +1

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


  1. reforms Автор
    28.08.2019 15:04
    +1

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

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


  1. radist2s
    28.08.2019 15:15
    +1

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


    1. findoff
      28.08.2019 19:35

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


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


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


      1. Использовать под интерфейсы. Меняя структуру данных.
      2. Делать юнионы, при разумных количествах комбинаций.
      3. Забить.


  1. VladVR
    28.08.2019 16:26

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

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


    1. LEXA_JA
      28.08.2019 16:33
      +1

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


    1. VladVR
      28.08.2019 16:35

      невнимательно прочитал, оказывается речь шла не про assertion, а про подавление warning optType.maybe!.data


  1. morsic
    28.08.2019 21:57
    +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'}


    1. melodyn
      29.08.2019 07:44

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


      [{key: 'a', value: 'b'}, {key: 'b', value: 'c'}].reduce((acc, elem) => ({ ...acc, [elem.key]: elem.value }), {})


      1. morsic
        29.08.2019 09:42

        Я про конвертацию типов а не реальных значений


        1. Cerberuser
          29.08.2019 10:01

          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 ><


          1. morsic
            29.08.2019 21:30

            Блин, прикольно, даже не думал об таком подходе


    1. reforms Автор
      29.08.2019 09:59

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


      1. vintage
        29.08.2019 10:26

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


    1. movl
      29.08.2019 23:55

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

      Если Вы говорили конкретно про 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/


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


      1. morsic
        30.08.2019 01:29

        Не, я в целом имел ввиду подход к итерации на уровне типов


  1. arswarog
    29.08.2019 12:39

    Магия 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]>; 
    }


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


    1. vintage
      29.08.2019 13:35

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


      1. arswarog
        29.08.2019 13:42

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


        1. vintage
          29.08.2019 15:43

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


    1. reforms Автор
      29.08.2019 14:50

      Задачка интересная. Сходу мне решить ее не удалось. Подумаю на досуге.


  1. saaivs
    29.08.2019 13:01

    Cистема типов TypeScript явялется Тьюринг-полной github.com/Microsoft/TypeScript/issues/14833

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