TypeScript: 11 советов, которые улучшат ваши навыки

Источник: «11 Tips That Make You a Better Typescript Programmer»
Изучение TypeScript часто является путешествием с повторными открытиями. Ваше первоначальное впечатление может быть довольно обманчивым: разве это не способ аннотации JavaScrip, чтобы компилятор помог найти потенциальные ошибки?

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

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

1. Думайте в Множествах

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

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

type Measure = { radius: number };
type Style = { color: string };

type Circle = Measure & Style; // typed { radius: number; color: string }

Если вы интерпретируете оператор & в смысле логического AND, вы можете ожидать, что Circle будет фиктивным типом, потому что это соединение двух типов без каких-либо перекрывающихся полей. Это не то как работает TypeScrip. Вместо этого, думая в Множествах, гораздо проще вывести правильное поведение:

Множества также помогают понять возможность присваивания: присваивание разрешено только в том случае, если тип значения является подмножеством типа назначения:

type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';

shape = foo; // запрещено, поскольку string не является подмножеством ShapeKind
foo = shape; // разрешено, поскольку ShapeKind является подмножеством string

Статья TypeScript and Set Theory превосходное введение в мышление в Множествах.

2. Понимание объявление и сужение типа

Одна из чрезвычайно мощных функций TypeScript — автоматическое сужение типа на основе потока управления. Это означает, что переменная имеет два типа, связанных с ней в любой конкретной точке кода: объявленный тип и суженый тип.

function foo(x: string | number) {
if (typeof x === 'string') {
// Тип x сужается до string, поэтому .length допустим
console.log(x.length);

// присваивание учитывает объявленный тип, а не суженный тип
x = 1;
console.log(x.length); // запрещено, потому что x теперь number
} else {
...
}
}

3. Используйте исключающие объединения вместо опциональных полей

При определении множества полиморфных типов, таких как Shape, легко начать с:

type Shape = {
kind: 'circle' | 'rect';
radius?: number;
width?: number;
height?: number;
}

function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius! ** 2
: shape.width! * shape.height!;
}

Ненулевые утверждения (при доступе к полям radius, width и height) необходимы, поскольку нет установленной связи между kind и другими полями. Вместо этого исключающее объединение — гораздо лучшее решение:

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius ** 2
: shape.width * shape.height;
}

Сужение типов устранило необходимость в приведения (неявного преобразования значений из одного типа в другой).

4. Используйте предикат типа, чтобы избежать утверждения типа

Если вы правильно используете TypeScript, вы будете редко использовать явное утверждение типа (например value as SomeType); однако иногда вы всё равно будет чувствовать побуждение, например:

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function isCircle(shape: Shape) {
return shape.kind === 'circle';
}

function isRect(shape: Shape) {
return shape.kind === 'rect';
}

const myShapes: Shape[] = getShapes();

// ошибка, потому что typescript не знает, что фильтрация сужает множество
const circles: Circle[] = myShapes.filter(isCircle);

// возможно вы склонны добавить утверждение:
// const circles = myShapes.filter(isCircle) as Circle[];

Более элегантное решение состоит в том, чтобы изменить isCircle и isRectтак, чтобы они вместо этого возвращали предикат типа, чтобы они помогли TypeScript ещё более сузить типы после вызова фильтра:

function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}

function isRect(shape: Shape): shape is Rect {
return shape.kind === 'rect';
}

...
// теперь вы получаете правильно выведенный тип Circle[]
const circles = myShapes.filter(isCircle);

5. Контролируйте как распределяется объединение типов

Вывод типа — это инстинкт TypeScript; большую часть времени он работает незаметно. Однако может потребовать вмешательство в тонких случая двусмысленности. Распределённые условные типы — один из таких случаев.

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

type ToArray<T> = T extends Array<unknown> ? T: T[];

Как вы думаете, что должно быть выведено для следующего типа?

type Foo = ToArray<string|number>;

Ответ: string[] | number[]. Но это не однозначно. Почему бы не (string | number)[] вместо этого?

По умолчанию, когда TypeScript встречает тип объединение (string | number здесь) для параметра дженерика (T здесь), он распределяется на каждую составляющую, и поэтому вы получаете string[] | number[]. Это поведение можно изменить, используя специальный синтаксис и заключив T в пару [], например:

type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;

Теперь Foo выводится как тип (string | number)[].

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

Когда используется switch-case вместо enum, это хорошая привычка активно ошибаться в case которые не ожидаются, вместо того, чтобы молча игнорировать их, как вы делаете в других языках программирования:

function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
throw new Error('Unknown shape kind');
}
}

С помощью TypeScript вы можете позволить статической проверке типов найти ошибку раньше, используя тип never:

function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
// вы получите ошибку типа ниже, если
// какой-либо shape.kind не обрабатывается выше
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape kind');
}
}

При этом невозможно забыть обновить функцию getArea при добавлении нового вида фигуры.

Смысл этого метода в том, что типу never нельзя присвоить ничего, кроме never. Если все кандидаты shape.kind исчерпываются операторами case, единственным возможным типом, достигающим default: будет never. Однако если какой-либо кандидат не покрыт, он попадёт в ветку default и приведёт к недопустимому значению.

7. type предпочтительнее interface

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

В противном случае постоянное использование type приводит к более согласованному коду.

8. tuple предпочтительное array, когда это уместно

Типы объектов — распространённый способ типизации структурированных данных, но иногда может понадобиться более краткое представление и вместо этого можно использовать просты массивы. Например, Circle можно определить как:

type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0]; // [kind, radius]

Но такой тип слишком размытый и можно сделать ошибку написав что-то вроде ['circle', '1.0']. Мы можем сделать его более строгим, используя Tuple вместо:

type Circle = [string, number];

// ниже, вы получите сообщение об ошибке
const circle: Circle = ['circle', '1.0'];

Хорошим примером использования tuple является useState в React.

const [name, setName] = useState('');

Это одновременно компактно и безопасно.

9. Контролируйте насколько общими или конкретными являются выведенные типы

TypeScript использует разумное поведение по умолчанию при выведении типов, чтобы упростить написание кода для распространённых случаев (поэтому типы не нужно явно аннотировать). Есть несколько способов изменить его поведение.

Мы получили ошибку, потому что в circle согласно объявлению типа NamedCircle, поле name действительно может быть undefined, несмотря на то, что инициализатор переменной предоставил значение string. Конечно, мы можем отказаться от аннотации типа NamedCircle, но мы потеряем проверку типа на соответствие объекту circle. Настоящая дилемма.

К счастью, в TypeScript 4.9 появилось новое ключевое слово satisfies, позволяющее проверять тип, не изменяя выводимы тип:

type NamedCircle = {
radius: number;
name?: string;
};

// ошибка потому что radius нарушает NamedCircle
const wrongCircle = { radius: '1.0', name: 'ha' } satisfies NamedCircle;

const circle = { radius: 1.0, name: 'yeah' } satisfies NamedCircle;

// теперь circle.name не может быть undefined
console.log(circle.name.length);

Модифицированная версия обладает обоими преимуществами: литерал объекта гарантированно соответствует типу NamedCircle, а выведенный тип имеет ненулевое поле имени.

10. Используйте infer для создания дополнительных параметров типа дженерик

При разработке служебных функций и типов вы часто будете ощущать потребность в использовании типа, извлечённого из заданного типа параметра. Ключевое слово infer пригодится в этой ситуации. Оно поможет вывести новый параметр на лету. Вот два простых примера:

// получаем развёрнутый тип из Promise; идемпотент если T не Promise
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string

// получаем тип Flatten массива T; идемпотент если T не массив
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number

То как ключевое слово infer работает в T extends Promise<infer U>, можно понять следующим образом: предположим, что T совместим с некоторым экземпляром общего типа (дженерика) Promise, импровизируйте параметр типа U, чтобы заставить его работать. Итак, если T создаётся как Promise<string>, решением U будет string.

11. Придерживайтесь DRY проявляя творческий подход к манипулированию типами

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

Используйте своё воображение и найдёте бесконечный потенциал для изучения.

Заключение

Эта статья охватывает набор относительно сложных тем в языке TypeScript. На практике вы обнаружите, что применять их напрямую не принято; однако такие методы широко используются библиотеками специально разработанными для TypeScript: такими кака Prisma и tRPC. Знакомство с этими приёмами поможет лучше понять, как эти инструменты работают со своей магией под капотом.

Дополнительные материалы

Предыдущая Статья

JavaScript: Полное руководство по модулям в браузере и Node.js

Следующая Статья

Laravel: Три вещи, которые нужны для TDD