Книга «Эффективный TypeScript: 62 способа улучшить код»
Привет, Хаброжители! Книга Дэна Вандеркама окажется максимально полезена тем, кто уже имеет опыт работы с JavaScript и TypeScript. Цель этой книги — не обучать читателей пользоваться инструментами, а помочь им повысить свой профессиональный уровень. Прочитав ее, вы сформируете лучшее представление о работе компонентов TypeScript, сможете избежать многих ловушек и ошибок и развить свои навыки. В то время как справочное руководство покажет пять разных путей применения языка для реализации одной задачи, «эффективная» книга объяснит, какой из этих путей лучше и почему.
Структура книги
Книга представляет собой сборник кратких эссе (правил). Правила объединены в тематические разделы (главы), к которым можно обращаться автономно в зависимости от интересующего вопроса.
Заголовок каждого правила содержит совет, поэтому ознакомьтесь с оглавлением. Если, например, вы пишете документацию и сомневаетесь, надо ли писать информацию типов, обратитесь к оглавлению и правилу 30 («Не повторяйте информацию типа в документации»).
Практически все выводы в книге продемонстрированы на примерах кода. Думаю, вы, как и я, склонны читать технические книги, глядя в примеры и лишь вскользь просматривая текстовую часть. Конечно, я надеюсь, что вы внимательно прочитаете объяснения, но основные моменты я отразил в примерах.
Прочитав каждый совет, вы сможете понять, как именно и почему он поможет вам использовать TypeScript более эффективно. Вы также поймете, если он окажется непригодным в каком-то случае. Мне запомнился пример, приведенный Скоттом Майерсом, автором книги «Эффективный C++»: разработчики ПО для ракет могли пренебречь советом о предупреждении утечки ресурсов, потому что их программы уничтожались при попадании ракеты в цель. Мне неизвестно о существовании ракет с системой управления, написанной на JavaScript, но такое ПО есть на телескопе James Webb. Поэтому будьте осторожны.
Каждое правило заканчивается блоком «Следует запомнить». Бегло просмотрев его, вы сможете составить общее представление о материале и выделить главное. Но я настоятельно рекомендую читать правило полностью.
Отрывок. ПРАВИЛО 4. Привыкайте к структурной типизации
JavaScript имеет неумышленную утиную типизацию: если вы передадите функции значение с верными свойствами, то ее не будет волновать, как вы получили это значение. Она просто его использует. TypeScript моделирует это поведение, что иногда приводит к неожиданным результатам, так как понимание типов модулем проверки может оказаться шире привычного вам. Развитие навыка структурной типизации позволит лучше чувствовать, где действительно есть ошибки, и писать более надежный код.
К примеру, вы работаете с библиотекой физических характеристик и у вас есть тип вектора 2D:
interface Vector2D {
x: number;
y: number;
}
Вы пишете функцию для вычисления его длины:
function calculateLength(v: Vector2D) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
и вводите определение вектора named:
interface NamedVector {
name: string;
x: number;
y: number;
}
Функция calculateLength будет работать с NamedVector, так как в нем присутствуют свойства x и y, являющиеся number. TypeScript это понимает:
const v: NamedVector = { x: 3, y: 4, name: 'Zee' };
calculateLength(v); // ok, результат равен 5.
Интересно то, что вы не объявляли связь между Vector2D и NamedVector. Вам также не пришлось прописывать альтернативное выполнение calculateLength для NamedVector. Система типов TypeScript моделирует поведение JavaScript при выполнении (правило 1), что позволило NamedVector вызвать calculateLength на основании того, что его структура сопоставима с Vector2D. Отсюда выражение «структурная типизация».
Но это также может привести и к проблемам. Допустим, вы добавите тип вектора 3D:
interface Vector3D {
x: number;
y: number;
z: number;
}
и напишете функцию, чтобы нормализовать векторы (сделать их length равной 1):
function normalize(v: Vector3D) {
const length = calculateLength(v);
return {
x: v.x / length,
y: v.y / length,
z: v.z / length,
};
}
Если вы вызовете эту функцию, то, вероятнее всего, получите больше, чем единичную длину:
> normalize({x: 3, y: 4, z: 5})
{ x: 0.6, y: 0.8, z: 1 }
Что же пошло не так и почему TypeScript не сообщил об ошибке?
Баг заключается в том, что calculateLength работает с векторами 2D, а normalize — с 3D. Поэтому компонент z игнорируется при нормализации.
Может показаться странным, что модуль проверки типов не уловил этого. Почему допускается вызов calculateLength 3D-вектором, несмотря на то что ее тип работает с 2D-векторами?
То, что работало хорошо с named, здесь привело к обратному результату. Вызов calculateLength объектом {x, y, z} не выдает ошибку. Следовательно, модуль проверки типов не жалуется, что в итоге приводит к появлению бага. Если вы захотите, чтобы в подобном случае ошибка все же обнаруживалась, обратитесь к правилу 37.
Прописывая функции, легко представить, что они будут вызываться свойствами, которые вы объявили, и никакими другими. Это называется «запечатанный», или «точный», тип и не может быть применено в системе типов TypeScript. Нравится вам это или нет, но здесь типы открыты.
Иногда это приводит к сюрпризам:
function calculateLengthL1(v: Vector3D) {
let length = 0;
for (const axis of Object.keys(v)) {
const coord = v[axis];
// ~~~~~~~ Элемент неявно имеет тип "any", потому что
// тип "string" не может быть использован
// для обозначения типа "Vector3D"
length += Math.abs(coord);
}
return length;
}
Почему это ошибка? Поскольку axis является одним из ключей v из Vector3D, то он должен быть x, y или z. А согласно изначальному объявлению Vector3D, они все являются numbers. Следовательно, не должен ли тип coord также быть number?
Это не ложная ошибка. Мы знаем, что Vector3D строго определен и не имеет иных свойств. Хотя мог бы:
const vec3D = {x: 3, y: 4, z: 1, address: '123 Broadway'};
calculateLengthL1(vec3D); // ok, возвращает NaN
Поскольку v, вероятно, мог иметь любые свойства, то тип axis является string. У TypeScript нет причин считать v[axis] только числом. При итерации объектов может быть сложно добиться корректной типизации. Мы вернемся к этой теме в правиле 54, а сейчас обойдемся без циклов:
function calculateLengthL1(v: Vector3D) {
return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}
Структурная типизация может также служить причиной сюрпризов в классах, которые сравниваются на предмет возможного назначения свойств:
class C {
foo: string;
constructor(foo: string) {
this.foo = foo;
}
}
const c = new C('instance of C');
const d: C = { foo: 'object literal' }; // ok!
Почему d может быть назначен для C? У него есть свойство foo, являющееся string. Еще у него есть constructor (из Object.prototype), который может быть вызван аргументом (хотя обычно он вызывается без него). Итак, структуры совпадают. Это может привести к неожиданностям, если у вас присутствует логика в конструкторе C и вы напишете функцию, подразумевающую его запуск. В этом существенное отличие от языков вроде C++ или Java, где объявления параметра типа C гарантирует, что он будет принадлежать именно C либо его подклассу.
Структурная типизация хорошо помогает при написании тестов. Допустим у вас есть функция, которая выполняет запрос в базу данных и обрабатывает результат.
interface Author {
first: string;
last: string;
}
function getAuthors(database: PostgresDB): Author[] {
const authorRows = database.runQuery(`SELECT FIRST, LAST FROM
AUTHORS`);
return authorRows.map(row => ({first: row[0], last: row[1]}));
}
Чтобы ее протестировать, вы могли бы создать имитацию PostgresDB. Однако лучшим решением будет использование структурной типизации и определение более узкого интерфейса:
interface DB {
runQuery: (sql: string) => any[];
}
function getAuthors(database: DB): Author[] {
const authorRows = database.runQuery(`SELECT FIRST, LAST FROM
AUTHORS`);
return authorRows.map(row => ({first: row[0], last: row[1]}));
}
Вы по-прежнему можете передать postgresDB функции getAuthors в вывод, поскольку в ней есть метод runQuery. Структурная типизация не обязывает PostgresDB сообщать, что она выполняет DB. TypeScript сам определит это.
При написании тестов вы можете передавать и более простой объект:
test('getAuthors', () => {
const authors = getAuthors({
runQuery(sql: string) {
return [['Toni', 'Morrison'], ['Maya', 'Angelou']];
}
});
expect(authors).toEqual([
{first: 'Toni', last: 'Morrison'},
{first: 'Maya', last: 'Angelou'}
]);
});
TypeScript определит, что тестовый DB соответствует интерфейсу. В то же время ваши тесты совершенно не нуждаются в информации о базе данных вывода: не требуется никаких имитированных библиотек. Введя абстракцию (DB), мы освободили логику от деталей выполнения (PostgresDB).
Еще одним преимуществом структурной типизации является то, что она способна четко обрывать зависимости между библиотеками. Больше информации по этой теме вы найдете в правиле 51.
СЛЕДУЕТ ЗАПОМНИТЬJavaScript применяет утиную типизацию, а TypeScript ее моделирует при помощи структурной типизации. В связи с этим значения, присваиваемые вашим интерфейсам, могут иметь свойства, не указанные в объявленных типах. Типы в TypeScript не бывают запечатанными.
Имейте в виду, что классы также подчиняются правилам структурной типизации. Поэтому вы можете получить не тот образец класса, какой ожидали.
Используйте структурную типизацию для упрощения тестирования элементов.
ПРАВИЛО 5. Ограничьте применение типов any
Система типов в TypeScript является постепенной и выборочной. Постепенность проявлена в возможности добавлять типы в код шаг за шагом, а выборочность — в возможности отключения модуля проверки типов, когда вам это нужно. Ключом к управлению в данном случае выступает тип any:
let age: number;
age = '12';
// ~~~ Тип '"12"' не может быть назначен для типа 'number'.
age = '12' as any; // ok
Модуль проверки прав, указывая на ошибку, но ее обнаружение можно исключить, просто добавив as any. Когда вы начинаете работать с TypeScript, становится заманчивым использование типов any или утверждений as any при непонимании ошибки, недоверии к модулю проверки или нежелании тратить время на прописывание типов. Но помните, что any нивелирует многие преимущества TypeScript, а именно:
Снижает безопасность кода
В примере выше, согласно объявленному типу, age является number. Но any позволил назначить для него строку. Модуль проверки будет считать, что это число (ведь именно так вы объявили), что приведет к появлению путаницы.
age += 1; // ok. При выполнении получится age "121".
Позволяет нарушать условия
Создавая функцию, вы ставите условие, что, получив от вызова определенный тип данных, она произведет соответствующий тип при выводе, которое нарушается так:
function calculateAge(birthDate: Date): number {
// ...
}
let birthDate: any = '1990-01-19';
calculateAge(birthDate); // ok
Параметр даты рождения birthDate должен быть Date, а не string. Тип any позволил нарушить условие, относящееся к calculateAge. Это может оказаться особенно проблематичным, так как JavaScript имеет склонность к неявной конвертации типов. Из-за этого string в некоторых случаях сработает там, где предполагается number, но неизбежно даст сбой в другом месте.
Исключает поддержку языковой службы
Если символу присвоен тип, языковые службы TypeScript способны предоставить соответствующую автоподстановку и контекстную документацию (рис. 1.3).
Однако, присваивая символам тип any, вам придется все делать самостоятельно (рис. 1.4).
И переименование тоже. Если у вас есть тип Person и функции для форматирования имени:
interface Person {
first: string;
last: string;
}
const formatName = (p: Person) => `${p.first} ${p.last}`;
const formatNameAny = (p: any) => `${p.first} ${p.last}`;
тогда вы можете выделить first в редакторе и выбрать Rename Symbol для его переименования в firstName (рис. 1.5 и рис. 1.6).
Это изменит функцию formatName, но не в случае с any:
interface Person {
first: string;
last: string;
}
const formatName = (p: Person) => `${p.firstName} ${p.last}`;
const formatNameAny = (p: any) => `${p.first} ${p.last}`;
» Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок
Для Хаброжителей скидка 25% по купону — TypeScript
По факту оплаты бумажной версии книги на e-mail высылается электронная книга.