Простые TypeScript-хитрости, которые позволят масштабировать ваши приложения бесконечно +35


Мы используем TypeScript, потому что это делает разработку безопаснее и быстрее.

Но, на мой взгляд, TypeScript из коробки содержит слишком много послаблений. Они помогают сэкономить немного времени JavaScript-разработчикам при переходе на TS, но съедают очень много времени в долгосрочной перспективе.

Я собрал ряд настроек и принципов для более строгого использования TypeScript. К ним нужно привыкнуть один раз — и они сэкономят массу времени в будущем.

any

Самое простое правило, которое дает моей команде много профита в долгосрочной перспективе: 

Не использовать any.

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

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

Если все же вам нужно опустить типизацию, советую воспользоваться алиасами на any, почитать о которых можно в статье моего коллеги — совет № 3.

strict

В TypeScript есть классный strict-режим, который, к сожалению, отключен по умолчанию. Он включает набор правил для безопасной и комфортной работы с TypeScript. Если вы совсем не знакомы с этим режимом, прочтите вот эту статью.

Со strict-режимом вы забудете про ошибки вроде undefined is not a function и cannot read property X of null. Ваши типы будут точными и правильными.

А что делать-то?

Если стартуете новый проект, то просто сразу включайте strict и будьте счастливы.

Если у вас уже есть проект без strict-режима и теперь вы захотели включить его, то вы столкнетесь с рядом трудностей. Очень сложно писать строгий код, когда компилятор никак не сигнализирует о проблемах. Так что, вероятно, у вас будет много мест с ошибками, а процесс миграции очень быстро надоедает.

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

Но в больших проектах бывает не все так гладко, тогда можно действовать более итеративно. Включите флаг, а над всеми конфликтами поставьте @ts-ignore и комментарий с TODO. В следующий раз при работе с файлом заодно поправите и тип.

// tsconfig.json file
{
    // ...,
    "compilerOptions": {
        // a set of cool rules
        "noImplicitAny": true,
        "noImplicitThis": true,
        "strictNullChecks": true,
        "strictPropertyInitialization": true,
        "strictBindCallApply": true,
        "strictFunctionTypes": true,
        // a shortcut enabling 6 rules above
        "strict": true,
        // ...
    }
}

readonly

Следующее важное для меня правило — везде писать readonly.

Мутировать структуры, с которыми работаешь, — плохая практика. Например, у нас в Angular-мире это сразу приводит к ряду последствий в приложении: появляются проблемы с проверкой изменений в компонентах, за которыми не происходит обновления отображения после мутирования данных.

Но можем ли мы легко предотвратить все попытки мутировать данные? Для себя я просто сформировал привычку писать readonly везде.

Что делать?

В вашем приложении, скорее всего, есть множество мест, где можно заменить небезопасные типы их readonly-альтернативами.

Используйте readonly в интерфейсах:

// before
export interface Thing {
    data: string;
}

// after
export interface Thing {
    readonly data: string;
}

Предпочитайте readonly в типах:

// Before
export type UnsafeType = { prop: number };

// After
export type SafeType = Readonly<{ prop: number }>;

Используйте readonly поля класса везде, где это возможно:

// Before
class UnsafeComponent {
    loaderShow$ = new BehaviorSubject<boolean>(true);
}

// After
class SafeComponent {
    readonly loaderShow$ = new BehaviorSubject<boolean>(true);
}

Используйте readonly-структуры:

// Before
const unsafeArray: Array<number> = [1, 2, 3];
const unsafeArrayOtherWay: number[] = [1, 2, 3];

// After
const safeArray: ReadonlyArray<number> = [1, 2, 3];
const safeArrayOtherWay: readonly number[] = [1, 2, 3];

// three levels
const unsafeArray: number[] = [1, 2, 3]; // bad
const safeArray: readonly number[] = [1, 2, 3]; // good
const verySafeTuple: [number, number, number] = [1, 2, 3]; // super
const verySafeTuple: readonly [number, number, number] = [1, 2, 3]; // awesome (after v3.4)

// Map:
// Before
const unsafeMap: Map<string, number> = new Map<string, number>();

// After
const safeMap: ReadonlyMap<string, number> = new Map<string, number>();


// Set:
// Before
const unsafeSet: Set<number> = new Set<number>();

// After
const safeSet: ReadonlySet<number> = new Set<number>();

as const

В TypeScript v3.4 появились const-assertions. Это более строгий инструмент, чем readonly-типы, потому что он запаковывает вашу константу с наиболее точным типом из возможных. Теперь можно быть уверенным: никто и ничто не сможет это изменить.

Кроме того, при использовании as const ваша IDE всегда будет показывать точный тип используемой сущности.

Utility Types

В TypeScript есть набор типов, которые являются шорткатами преобразований других типов.

Советую подробно изучить всю официальную документацию по Utility Types и начать активно внедрять их в свои приложения. Они тоже экономят массу времени.

Сужения типов

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

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

(этот же пример можно запустить на repl.it)

import {Subject} from 'rxjs';
import {filter} from 'rxjs/operators';

interface Data {
  readonly greeting: string;
}

const data$$ = new Subject<Data | null>();

/**
 * source$ все еще имеет тип "Observable<Data | null>"
 * хотя "null" никогда не пройдет функцию filter
 * 
 * Это произошло, потому что TS не может быть уверен, что тип данных изменился.
 * Функция "value => !!value" возвращает boolean, но ничего не говорит о типах
 */
const source$ = data$$.pipe(
  filter(value => !!value)
)

/** 
 * А вот это хорошо типизированный пример
 * 
 * Эта стрелочная функция отвечает на вопрос, является ли value типом Data.
 * Это сужает тип, и теперь "wellTypedSource$" типизирован правильно
 */
const wellTypedSource$ = data$$.pipe(
  filter((value): value is Data => !!value)
)

// Это не скомпилируется, можете проверить :)
// source$.subscribe(x => console.log(x.greeting));

wellTypedSource$.subscribe(x => console.log(x.greeting));

data$$.next({ greeting: 'Hi!' });

Вы можете сужать типы несколькими методами:

  • typeof — оператор из JavaScript для проверки примитивных типов.

  • instanceof — оператор из JavaScript для проверки унаследованных сущностей.

  • is T — декларирование из TypeScript, которое позволяет проверять сложные типы или интерфейсы. Будьте осторожны с этой возможностью, потому что так вы перекладываете ответственность за определение типа с TS’а на себя.

Несколько примеров:

(эти же примеры можно запустить на repl.it)

// typeof narrowing
function getCheckboxState(value: boolean | null): string {
   if (typeof value === 'boolean') {
       // value has "boolean" only type
       return value ? 'checked' : 'not checked';
   }

   /**
    * В этой области видимости value имеет тип “null”
    */
   return 'indeterminate';
}

// instanceof narrowing
abstract class AbstractButton {
   click(): void { }
}

class Button extends AbstractButton {
   click(): void { }
}

class IconButton extends AbstractButton {
   icon = 'src/icon';

   click(): void { }
}

function getButtonIcon(button: AbstractButton): string | null {
   /**
    * После "instanceof" TS знает, что у объекта кнопки есть поле "icon"
    */
   return button instanceof IconButton ? button.icon : null;
}

// is T narrowing
interface User {
   readonly id: string;
   readonly name: string;
}

function isUser(candidate: unknown): candidate is User {
   return (
       typeof candidate === "object" &&
       typeof candidate.id === "string" &&
       typeof candidate.name === "string"
   );
}

const someData = { id: '42', name: 'John' };

if (isUser(someData)) {
   /**
    * Теперь TS знает, что someData имплементирует интерфейс User
    */
   console.log(someData.id, someData.name)
}

Итого

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




К сожалению, не доступен сервер mySQL