TypeScript: 11 советов, которые улучшат ваши навыки
Хотя это утверждение в целом верно, по мере продвижения вы обнаружите, что самая невероятная мощь языка заключается в составлении, выведении и манипулировании типами.
В этой статье будут обобщены несколько советов, которые помогут использовать весь потенциал языка TypeScript.
1. Думайте в Множествах
Тип — повседневное понятие для программистов, но дать ему краткое определение на удивление сложно. Вместо этого я считаю полезным использовать Множества в качестве концептуальной модели.
Например, только начавшие изучать TypeScript находят способ составления типов нелогичным. Возьмём очень простой пример:
type Measure = { radius: number };
type Style = { color: string };
type Circle = Measure & Style; // typed { radius: number; color: string }
Если вы интерпретируете оператор &
в смысле логического AND
, вы можете ожидать, что Circle
будет фиктивным типом, потому что это соединение двух типов без каких-либо перекрывающихся полей. Это не то как работает TypeScrip. Вместо этого, думая в Множествах, гораздо проще вывести правильное поведение:
- Каждый тип представляет собой Множество значений.
- Некоторые множества бесконечны:
string
,number
; некоторые конечны:boolean
,undefined
, … unknown
— Это Универсальное Множество (включающий все значения), аnever
— Пустое Множество (не содержащее значений)- Тип
Measure
— это Множество для всех объектов, содержащих полеnumber
, называемоеradius
. То же самое соStyle
- Оператор
&
создаёт ПересечениеMeasure
иStyle
обозначая Множество объектов содержащих как поляradius
, так и поляcolor
, которое фактически является меньшим Множеством, но с более общедоступными полями. - Точно так же оператор
|
создаёт Объединение: большее Множество, но потенциально с меньшим количество общедоступных полей (если содержит два типа).
Множества также помогают понять возможность присваивания: присваивание разрешено только в том случае, если тип значения является подмножеством типа назначения:
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
использовать только в том случае, если верно одно из следующих условий:
- Вы хотите воспользоваться функцией
слияния
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 использует разумное поведение по умолчанию при выведении типов, чтобы упростить написание кода для распространённых случаев (поэтому типы не нужно явно аннотировать). Есть несколько способов изменить его поведение.
Используйте
const
, чтобы сузить до наиболее специфичного типаlet foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }
let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]
let circle = { kind: 'circle' as const, radius: 1.0 }; // typed { kind: 'circle; radius: number }
// следующая строка не будет работать, если circle не был инициализирован ключевым словом const
let shape: { kind: 'circle' | 'rect' } = circle;Используйте
satisfies
для проверки типа, не влияя на предполагаемый типtype NamedCircle = {
radius: number;
name?: string;
};
const circle: NamedCircle = { radius: 1.0, name: 'yeah' };
// ошибка потому что circle.name может быть undefined
console.log(circle.name.length);
Мы получили ошибку, потому что в 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 предоставляет мощный синтаксис для работы с типами и набор полезных утилит, которые помогут вам свести к минимуму дублирование кода. Вот лишь несколько специальных примеров:
Вместо дублирования объявления полей:
type User = { age: number; gender: string; country: string; city: string };
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };, используйте утилиту
Pick
для извлечения новых типов:type User = { age: number; gender: string; country: string; city: string };
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;Вместо дублирования возвращаемого типа функций
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: { kind: 'circle'; radius: number }) {
...
}
transformCircle(createCircle());, используйте
ReturnType<T>
для его извлечения:function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: ReturnType<typeof createCircle>) {
...
}
transformCircle(createCircle());Вместо параллельной синхронизации формы двух типов (
typeof config
иFactory
):type ContentTypes = 'news' | 'blog' | 'video';
// config для указания того, какие типы контента включены
const config = { news: true, blog: true, video: false } satisfies Record<ContentTypes, boolean>;
// factory для создания контента
type Factory = {
createNews: () => Content;
createBlog: () => Content;
};, используйте Сопоставленные Типы (Mapped Type) и Шаблонный литеральный тип (Template Literal Types), чтобы автоматически определить правильный тип
Factory
на основе формыconfig
type ContentTypes = 'news' | 'blog' | 'video';
// общий тип factory type с предполагаемым списком методов
// на основе формы данной Config
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
[k in string & keyof Config as Config[k] extends true
? `create${Capitalize<k>}`
: never]: () => Content;
};
// config для указания того, какие типы контента включены
const config = { news: true, blog: true, video: false } satisfies Record<ContentTypes, boolean>;
type Factory = ContentFactory<typeof config>;
// Factory: { createNews: () => Content; createBlog: () => Content; }
Используйте своё воображение и найдёте бесконечный потенциал для изучения.
Заключение
Эта статья охватывает набор относительно сложных тем в языке TypeScript. На практике вы обнаружите, что применять их напрямую не принято; однако такие методы широко используются библиотеками специально разработанными для TypeScript: такими кака Prisma и tRPC. Знакомство с этими приёмами поможет лучше понять, как эти инструменты работают со своей магией под капотом.