Чистый код для TypeScript — Часть 2

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



    Объекты и структуры данных


    Используйте иммутабельность


    Система типов в TypeScript позволяет помечать отдельные свойства интерфейса/класса как readonly поля (только для чтения). Это позволяет вам работать функционально (неожиданная мутация это плохо). Для более сложных сценариев есть встроенный тип Readonly, который принимает тип T и помечает все его свойства только для чтения с использованием mapped types (смотрите mapped types).


    Плохо:


    interface Config {
      host: string;
      port: string;
      db: string;
    }

    Хорошо:


    interface Config {
      readonly host: string;
      readonly port: string;
      readonly db: string;
    }

    В случае массива вы можете создать массив только для чтения, используя ReadonlyArray<T>. который не позволяет делать изменения с использованием push() и fill(), но можно использовать concat() и slice() они не меняют значения.


    Плохо:


    const array: number[] = [ 1, 3, 5 ];
    array = []; // error
    array.push(100); // array will updated

    Хорошо:


    const array: ReadonlyArray<number> = [ 1, 3, 5 ];
    array = []; // error
    array.push(100); // error

    Объявление аргументов только для чтения TypeScript 3.4 is a bit easier.


    function hoge(args: readonly string[]) {
      args.push(1); // error
    }

    Предпочтение const assertions для литеральных значений.


    Плохо:


    const config = {
      hello: 'world'
    };
    config.hello = 'world'; // значение изменено
    
    const array  = [ 1, 3, 5 ];
    array[0] = 10; // значение изменено
    
    // записываемые объекты возвращаются
    function readonlyData(value: number) {
      return { value };
    }
    
    const result = readonlyData(100);
    result.value = 200; // значение изменено

    Хорошо:


    // объект только для чтения
    const config = {
      hello: 'world'
    } as const;
    config.hello = 'world'; // ошибка
    
    // массив только для чтения
    const array  = [ 1, 3, 5 ] as const;
    array[0] = 10; // ошибка
    
    // Вы можете вернуть объект только для чтения
    function readonlyData(value: number) {
      return { value } as const;
    }
    
    const result = readonlyData(100);
    result.value = 200; // ошибка

    Типы vs. интерфейсы


    Используйте типы, когда вам может понадобиться объединение или пересечение. Используйте интерфейс, когда хотите использовать extends или implements. Однако строгого правила не существует, используйте то, что работает у вас. Для более подробного объяснения посмотрите это ответы о различиях между type and interface в TypeScript.


    Плохо:


    interface EmailConfig {
      // ...
    }
    
    interface DbConfig {
      // ...
    }
    
    interface Config {
      // ...
    }
    
    //...
    
    type Shape = {
      // ...
    }

    Хорошо:


    
    type EmailConfig = {
      // ...
    }
    
    type DbConfig = {
      // ...
    }
    
    type Config  = EmailConfig | DbConfig;
    
    // ...
    
    interface Shape {
      // ...
    }
    
    class Circle implements Shape {
      // ...
    }
    
    class Square implements Shape {
      // ...
    }

    Классы


    Классы должны быть маленькими


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


    Плохо:


    class Dashboard {
      getLanguage(): string { /* ... */ }
      setLanguage(language: string): void { /* ... */ }
      showProgress(): void { /* ... */ }
      hideProgress(): void { /* ... */ }
      isDirty(): boolean { /* ... */ }
      disable(): void { /* ... */ }
      enable(): void { /* ... */ }
      addSubscription(subscription: Subscription): void { /* ... */ }
      removeSubscription(subscription: Subscription): void { /* ... */ }
      addUser(user: User): void { /* ... */ }
      removeUser(user: User): void { /* ... */ }
      goToHomePage(): void { /* ... */ }
      updateProfile(details: UserDetails): void { /* ... */ }
      getVersion(): string { /* ... */ }
      // ...
    }

    Хорошо:


    class Dashboard {
      disable(): void { /* ... */ }
      enable(): void { /* ... */ }
      getVersion(): string { /* ... */ }
    }
    
    // разделить обязанности, переместив оставшиеся методы в другие классы
    // ...

    Высокая сплоченность низкая связь


    Сплоченность определяет степень, в которой члены класса связаны друг с другом. В идеале все поля в классе должны использоваться каждым методом. Мы говорим, что класс максимально связный. На практике это, однако, не всегда возможно и даже нежелательно. Однако вы должны добиваться, того чтобы сплоченность была высокой.


    Связанность относится и к тому, как связаны или зависимы два класса друг от друга. Классы считаются слабосвязанными если изменения в одном из них не влияют на другой.


    Плохо:


    class UserManager {
      // Плохо: каждая закрытая переменная используется той или иной группой методов.
      // Это ясно показывает, что класс несет больше, чем одну ответственность
      // Если мне нужно только создать сервис, чтобы получить транзакции для пользователя,
      // Я все еще вынужден передавать экземпляр  `emailSender`.
      constructor(
        private readonly db: Database,
        private readonly emailSender: EmailSender) {
      }
    
      async getUser(id: number): Promise<User> {
        return await db.users.findOne({ id });
      }
    
      async getTransactions(userId: number): Promise<Transaction[]> {
        return await db.transactions.find({ userId });
      }
    
      async sendGreeting(): Promise<void> {
        await emailSender.send('Welcome!');
      }
    
      async sendNotification(text: string): Promise<void> {
        await emailSender.send(text);
      }
    
      async sendNewsletter(): Promise<void> {
        // ...
      }
    }

    Хорошо:


    class UserService {
      constructor(private readonly db: Database) {
      }
    
      async getUser(id: number): Promise<User> {
        return await this.db.users.findOne({ id });
      }
    
      async getTransactions(userId: number): Promise<Transaction[]> {
        return await this.db.transactions.find({ userId });
      }
    }
    
    class UserNotifier {
      constructor(private readonly emailSender: EmailSender) {
      }
    
      async sendGreeting(): Promise<void> {
        await this.emailSender.send('Welcome!');
      }
    
      async sendNotification(text: string): Promise<void> {
        await this.emailSender.send(text);
      }
    
      async sendNewsletter(): Promise<void> {
        // ...
      }
    }

    Предпочитайте композицию наследованию


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


    Тогда вы можете спросить: "Когда я должен использовать наследование?" Это зависит от вашей проблемы, но это достойный список, когда наследование имеет больше смысла, чем композиция:


    1. Ваше наследование представляет собой "is-a" отношения а не "has-a" отношения (Human->Animal vs. User->UserDetails).
    2. Вы можете повторно использовать код из базовых классов (Люди могут двигаться как все животные).
    3. Вы хотите внести глобальные изменения в производные классы, изменив базовый класс. (Изменение расхода калорий у всех животных при их перемещении).

    Плохо:


    class Employee {
      constructor(
        private readonly name: string,
        private readonly email: string) {
      }
    
      // ...
    }
    
    // Плохо, потому что Employees "имеют" налоговые данные. EmployeeTaxData не является типом  Employee
    class EmployeeTaxData extends Employee {
      constructor(
        name: string,
        email: string,
        private readonly ssn: string,
        private readonly salary: number) {
        super(name, email);
      }
    
      // ...
    }

    Хорошо:


    class Employee {
      private taxData: EmployeeTaxData;
    
      constructor(
        private readonly name: string,
        private readonly email: string) {
      }
    
      setTaxData(ssn: string, salary: number): Employee {
        this.taxData = new EmployeeTaxData(ssn, salary);
        return this;
      }
    
      // ...
    }
    
    class EmployeeTaxData {
      constructor(
        public readonly ssn: string,
        public readonly salary: number) {
      }
    
      // ...
    }

    Используйте цепочки вызовов


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


    Плохо:


    class QueryBuilder {
      private collection: string;
      private pageNumber: number = 1;
      private itemsPerPage: number = 100;
      private orderByFields: string[] = [];
    
      from(collection: string): void {
        this.collection = collection;
      }
    
      page(number: number, itemsPerPage: number = 100): void {
        this.pageNumber = number;
        this.itemsPerPage = itemsPerPage;
      }
    
      orderBy(...fields: string[]): void {
        this.orderByFields = fields;
      }
    
      build(): Query {
        // ...
      }
    }
    
    // ...
    
    const queryBuilder = new QueryBuilder();
    queryBuilder.from('users');
    queryBuilder.page(1, 100);
    queryBuilder.orderBy('firstName', 'lastName');
    
    const query = queryBuilder.build();

    Хорошо:


    class QueryBuilder {
      private collection: string;
      private pageNumber: number = 1;
      private itemsPerPage: number = 100;
      private orderByFields: string[] = [];
    
      from(collection: string): this {
        this.collection = collection;
        return this;
      }
    
      page(number: number, itemsPerPage: number = 100): this {
        this.pageNumber = number;
        this.itemsPerPage = itemsPerPage;
        return this;
      }
    
      orderBy(...fields: string[]): this {
        this.orderByFields = fields;
        return this;
      }
    
      build(): Query {
        // ...
      }
    }
    
    // ...
    
    const query = new QueryBuilder()
      .from('users')
      .page(1, 100)
      .orderBy('firstName', 'lastName')
      .build();

    PS.
    Первая часть
    Третья часть
    Полный перевод

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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

      +1
      Ещё есть синтаксис для неизменяемых туплов: readonly [string, number]

      mariusschulz.com/blog/read-only-array-and-tuple-types-in-typescript
        +1
        class UserService

        Хорошо — давать нормальные имена классам, которые будут давать примерное представление что в этих классах находится.
        А «Service» это что?
        Если класс называется UserService в него всё-равно в итоге напихают всего, что хоть как-то связано с пользователем.

        Заголовок спойлера
        Раз уж взялись за связность, привели бы примеры уменьшения от связности — геттеры, например, повыпиливать, которые вы советовали делать в предыдущей статье.
          +1

          Документация по Angular, например, говорит что в сервисе происходит работа с данными. То есть созданный файл через angular-cli будет как раз называться user.service.ts, а класс в нем — UserService. Отсюда, мне кажется, что лучше не какие-то жесткие правила, а договоренности в определенной среде.


          В Angular придет новый разработчик и будет знать, что в UserService происходит работа с данными юзера и при необходимости нужно смотреть туда. В других фронтенд-фреймворках, насколько я знаю, таких договоренностей нет, поэтому там может быть и UserManager, и UserController, и UserStore, да и вообще что угодно.

            +1
            Договорённости конечно решают, и если такое соглашение на проекте есть то всё ок.
            Но их частенько нет, а UserService, UserManager и прочие абсолютно неиформативные названия есть, обычно из-за человеческой лени и нежелания думать над неймингом, а это важно.
            Иначе в один момент проект просто превращается в гору *Service и бесконтрольных связей.
              0
              Договоренности, договоренностями, но такие моменты еще можно и даже нужно отслеживать во время code revew.
          0

          Спасибо за статью! Хотел только отметить, что бОльшая часть правил из двух статей относится скорее не конкретно к TypeScript, а вообще к любому языку. Принципы разделения ответственности, иммутабельность и прочее — это просто Best Practices. Что касается именно TypeScript, то, на мой взгляд, следовало бы немного добавить про особенности фреймворков. Например, в Angular не рекомендуется ставить get на получение данных, используемых в шаблонах, так как это может привести к бесконечному Change Detection. В случае с Vue — get в TypeScript вполне успешно заменяет computed. И много других вариантов.


          Разумеется, я понимаю, что на TypeScript пишут не только frontend и не только на фреймворках.

            0
            Да вы правы это в основном общие правила, которые можно применять к любому языку. Здесь в основном делается упор на то как эти правила можно применить именно в TS.
            Мне ваше предложение по поводу рассмотреть какие-то особенности TypeScript и применяемость его в различных фреймворках, нравится. Подумаю о том чтобы выделить это отдельно.
            0

            Как и в первой есть спорные моменты.


            Замечания к первой части:


            1. Enum должны быть строковыми, числовые, особенно с неявными значениями (без знака =) — абсолютное зло, т.к. дебажить их нереально.
            2. Гетеры и сетеры — зло (об этом уже писали). Не нужно повышать уровень магии в коде. А валидация должна быть внешней, либо в специальном методе.

            Замечания ко второй части:


            1. Интерфейсы предпочтительнее типов, используйте типы только, если нельзя выразить интерфейсом. Пруф. type — это просто алис для типов (или комбинации типов), а interface полноценная сущность.
            2. Если и использовать fluent подход (цепочки), то обязательно иммутабельные (т.е. каждое звено должно генерировать клон). Но лучше обойтись, и писать явно.
              К примеру:
              const query = q('xxxx').where('y');
              const query2 = query.limit(2); // если цепочка будет мутабельной, то мы неявно поменяем и query, что часто хотелось бы избежать

              0
              Используйте иммутабельность

              Как связаны иммутабельность и readonly интерфейсы? Можно преспокойно заимплементить readonly интерфейс, состояние имплементации которого будет мутировать на ура.
              Классы должны быть маленькими
              Высокая сплоченность низкая связь
              Предпочитайте композицию наследованию
              Используйте цепочки вызовов

              А причем тут Typescript?
                0
                Это общие моменты подхода чистый код, и примеры как его применять в TypeScript
                  0

                  Хотел бы добавить про иммутабельность. Вообще, readonly действительно работает только с примитивами, то есть, когда речь идет о присваивании. Но, как уже было указано выше, существуют дополнительные типы вроде Readonly<YourInterface> или ReadonlyArray<SomeOtherInterface>, которые будут кидать ошибку при попытке изменить значение любым способом.


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

                    0
                    Readonly интерфейсы можно реализовать геттерами и вот тут уже как раз ни о какой иммутабельности речи не идет. Объект может поменять самостоятельно свое внутреннее состояние, а ссылка на объект будет та же.
                    Я это к тому, что если где-то в коде в Typescript вы получили readonly объект, не нужно ожидать, что он будет иммутабельным. Более того, это опасно ожидать, что он будет иммутабельным.
                      0

                      Может, есть какой-то более конкретный пример? Потому что пока это все выглядит как способ выстрелить себе в ногу. Да, если в классе стоит readonly someClass = new SomeClass() — это не означает, что он будет иммутабельный сам по себе. Это означает, что ему нельзя присвоить новое значение.


                      Если же это будет простой объект с данными или массив, то есть сущности, которые ничего не делают сами по себе, то тогда readonly или Readonly<ISomeInterface> — это еще какой выход.

                        +1
                        Может, есть какой-то более конкретный пример?

                        Какой-нибудь прокси класс или фасад, который может просто перепрокидывать данные из другого класса через readonly property, причем он может даже создавать их на лету при каждом вызове.
                        Надеятся на то, что в каждый момент времени через то же самое проперти одного и того же инстанса объекта будут приходить одни и те же данные не стоит. И вот это уже НЕ иммутабельность. Вопрос не в том, что readonly interface это плохо, я этого не говорил. Я лишь к тому, что readonly interface != immutability.

                        Readonly это круто, используйте это. Просто если вам нужна иммутабельность как она есть, то может стоит посмотреть в сторону каких-то решений, которые имеют отношение к иммутабельности. К примеру, github.com/immutable-js/immutable-js

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

                        Immutable data cannot be changed once created
                          0

                          Согласен, спасибо.

                  +1
                  как-то все вообщем, напихали название SOLID спагетти с typescript.

                  А еще вопрос, какой смысл от такого массива?

                  const array: ReadonlyArray = [ 1, 3, 5 ];

                  что тут можно понять, есть ридонли массив с какими-то значениями. что это за значения? Как они применяются? Какая верятность, что пушить в массив никто не будет?
                    0
                    Какая верятность, что пушить в массив никто не будет?

                    Высокая. Всё-таки TypeScript является статически типизированным, и конструкция array.push(5) в нём просто не скомпилируется.

                      –1

                      Боюсь, что скомпилируется: https://habr.com/ru/post/485068/#comment_21181668

                        0
                        Property 'push' does not exist on type 'readonly number[]' не скомпилится
                        0
                        Ну и есть высокая вероятность, что не будут, смысл такого массива не ясен
                          0
                          я имею ввиду следующее, часто Вам попадался на практике такой код и какого его применение?
                            0

                            Что именно попадалось?
                            ReadonlyArray<number>? Да, часто.
                            Литерал массива? Да, тоже часто.
                            Вот вместе, и в форме локальной переменной они мне не попадались.

                              0
                              Вот вместе, и в форме локальной переменной они мне не попадались. — я об этом
                                0

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

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

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