Typescript не идеален. Его ругают, но любят. Кто‑то даже не может представить свою жизнь без него так же, как не может представить жизнь без комфортного автомобиля. Тем не менее, у этого «автомобиля» в базовой комплектации есть существенные недостатки, которые каждый «автолюбитель» «чинит» по своему.
Один мой знакомый сравнил тайпскрипт с css браузеров, которому необходим свой собственный аналог css reset. И оказалось, что такой действительно есть. Речь идет о пакете, название которого говорит само за себя - ts-reset. За полгода своего существования на github ts-reset набрал 6 тысяч звезд, и мне показалось странным, что на Хабре я не нашел ни одной статьи, посвященной этому пакету. И если интересно, добро пожаловать под кат...
ts-reset
Что оно нам дает?
Начну с того, что у ts-reset прекрасная документация, которая помещается в один readme файл, что копировать оттуда все примеры его использования с «до» и «после» вижу избыточным. Поэтому пройдусь по ключевым моментам: все патчи типов, которые предоставляет ts-reset условно можно разделить на три группы:
те, которые делают проверку типов более строгой и как следствие более безопасной - в основном это замена
any
наunknown
везде, где это возможно. Например здесь, как в коде ниже для проверкиisArray
:
// BEFORE
const validate = (input: unknown) => {
if (Array.isArray(input)) {
console.log(input); // any[]
}
};
// AFTER
import "@total-typescript/ts-reset/is-array";
const validate = (input: unknown) => {
if (Array.isArray(input)) {
console.log(input); // unknown[]
}
};
те, которые делают проверку типов более мягкой и как следствие более удобной (для разработчиков) так, чтобы не сделать код менее типобезопасным. В основном такие патчи касаются смягчения сигнатур методов константных типов. Например, как с
includes
:
// BEFORE
const users = ["matt", "sofia"] as const;
const a = prompt('Enter name') || 'matt'
// Argument `string` is not assignable to type '"matt" | "sofia"':
if (users.includes(a)) alert(a)
// AFTER
import "@total-typescript/ts-reset/array-includes";
const users = ["matt", "sofia"] as const;
const a = prompt('Enter name') || 'matt'
// it's ok:
if (users.includes(a)) alert(a)
те, которые просто делают typescript умнее. Например с
filter(Boolean)
:
// BEFORE
const filteredArray = [1, 2, undefined].filter(Boolean); // (number | undefined)[]
// AFTER
import "@total-typescript/ts-reset/filter-boolean";
const filteredArray = [1, 2, undefined].filter(Boolean); // number[]
Одним словом ts-reset делает разработку на typescript более безопасной и более удобной.
Чего в ts-reset не хватает?
Нужно сказать, что ts-reset покрывает только базовые моменты, что в принципе и следует из его названия. Однако, признаться честно, когда я первый раз познакомился с этим пакетом, то меня посетили по очереди следующие мысли: «тьфу, да они почти ничего не сделали», «просто позаменять везде any
на unknown
ума много не надо», «все патчи простые, как один»... Посетили и прошли, потому что на то он и ts‑reset, чтобы пропатчить базовые вещи, а не все, что возможно. Говоря автомобильным слэнгом, задача ts-reset
- это сменить базовую
комплектацию на comfort
. И эта комплектация предусматривает:
патчи стандартной библиотеки типов ecmasript (никаких других патчей, например, lib dom, не предусмотрено)
патчи простых типов (ts-reset избегает патчей сложных типов. Например, вот этот issue, автор закрыл просто потому, что "дополнительная сложность добавления этого не стоила бы того")
types-spring
Но что делать, если comfort
вас не устраивает и вы согласны только на business
(или хотя бы - comfort+
)?
types-spring - это пакет, который вместил в себя все, что не предоставил ts-reset. Во всяком случае, постарался. Он, в отличие от ts-reset, содержит патчи типов, как для ecmascript, так и для наиболее встречающиеся методы из DOM:
Документация types-spring так же хорошо читаема и понятна, как и у ts-reset. Поэтому приведу всего лишь несколько примеров:
1. Type guarding патч для ReadonlyArray
Патч сигнатуры isArray
для ReadonlyArray
:
// BEFORE:
function checkArray(a: { a: 1 } | ReadonlyArray<number>)
{
if (Array.isArray(a)) {
a // any[]
}
}
// AFTER:
function checkArray(a: { a: 1 } | ReadonlyArray<number>)
{
if (Array.isArray(a)) {
a // readonly number[]
}
}
2. Патч сигнатуры Object.create:
// BEFORE:
let o = Object.create({}) // any
// AFTER:
let o = Object.create({}) // object
3. Патч сигнатуры Object.assign:
// BEFORE:
let t = Object.assign({ a: 7, b: 8 }, { b: '' }) // {a: number, b: never}
// AFTER:
let t = Object.assign({ a: 7, b: 8 }, { b: '' }) // {a: number, b: string}
И тому подобное... Больше примеров вы можете найти в документации. Но самая полезная часть (фича, так сказать) types-spring
- это патчи к lib dom. И на этом шаге я бы остановился подробнее.
патч для querySelector:
По дефолту querySelector
имеет несколько перегрузок, принимающих на вход произвольную строку либо ключ типа, который вернет соответствующий элемент. В первом случае querySelector
не знает, какой конкретно элемент запрашивает пользователь и возвращает просто тип... Element
. Таким образом, на выходе мы получаем этот элемент этого типа каждый раз, когда искомый селектор не совпадает с именем тега. Однако мы достоверно знаем, что когда запрашиваем
const elem = document.querySelector('.wrapper input.cls') // is Element
то селектор .wrapper input.cls
точно так же, как и input.cls
вернет либо HTMLInputElement
, либо null
и никакой другой. Что же делать?
Большинство разработчиков, которых я знаю, используют в таком случае для уточнения типа либо as
, либо передают явно тип элемента в качестве generic типа (querySelector<HTMLInputElement>
). Однако, что делать, если вы используете, скажем, JSDoc
, либо среди ваших коллег затерялся сотрудник, который по своей невнимательности позволил себе написать querySelector<HTMLInputElement>('div.cls')
?
Использование types-sping
могло бы сделать такой подход более типобезопасным:
const elem = document.querySelector('.wrapper input.cls') // elem is HTMLInputElement
Разумеется, это не серебряная пуля, и никакой тип не поможет определить, что вернет querySelector('.cls')
- в этом случае правила игры остаются те же и ответственность за правильную типизацию вернувшегося элемента ложится на исключительно плечи разработчика.
патч для addEventListener
Сразу оговорюсь, что речь идет именно о тех Event, которые являются событиями пользовательского интерфейса. Уверен, что хотя бы раз каждый разработчик, который прикоснулся к typescript и писал фронт, сталкивался с тем, что поля target
и currentTarget
объекта event
возвращали какой-то узкий, почти непригодный к эксплуатации тип (ну и разумеется, решали эту проблему через as
, как иначе :) ). Он называется EventTarget
и в исходных типах он захардкожен. Т.е. по сути мы получаем его всегда, даже когда явно знаем, что currentTarget
- это HTMLElement или какой-то другой объект, например, как тут:
let input = document.querySelector<HTMLInputElement>('input');
input?.addEventListener('focus', e => {
let v = e.currentTarget?.value // мы получим ошибку
})
В примере выше мы получим ошибку, т.к. EventTarget
не содержит поле value
. Но types-spring
позволяет нам вывести тип currentTarget
из вызывающего объекта input
:
let input = document.querySelector<HTMLInputElement>('input');
input?.addEventListener('focus', e => {
let v = e.currentTarget?.value // currentTarget is HTMLInputElement
})
С target
все, к сожалению, несколько сложнее. Он действительно может быть Node
, а не HTMLElement
, когда событие вызвано из Node
. Возьмем следующий пример:
var a = document.createTextNode('a')
a.addEventListener('click', e => console.log(e.target instanceof HTMLElement))
a.dispatchEvent(new MouseEvent('click'))
На выходе мы получим false
, поскольку e.target
является текстовой Node
. Так же любое UIEvent
может быть вызвано искусственно и из самого EventTarget
, созданного с помощью new EventTarget()
. И тогда поле target
в рантайме будет иметь тип... EventTarget
.
Конечно, это все примеры из сферического вакуума, но возможные. Тем не менее с учетом эти закономерностей можно твердо утверждать, что если событие пользовательского интерфейса вызвано не искусственно (является isTrusted), то target
будет являться как минимум Node
. Если же искусственно, то оно будет идентично currentTarget
. (просьба поправить (ну или привести пример, доказывающий обратное), если в этих рассуждениях есть прокол).
Патч для HTMLElement.cloneNode
Теперь HTMLElement.cloneNode
всегда возвращает HTMLElement
: очевидно, что HTMLElement после того, как был скопирован (склонирован), вряд ли перестанет быть HTMLElement
// BEFORE:
const elem = document.getElementById('id') // elem is HTMLElement
const clonedElem = elem?.cloneNode() // clonedElem is Node | null
// AFTER:
const elem = document.getElementById('id') // elem is HTMLElement
const clonedElem = elem?.cloneNode() // clonedElem is HTMLElement|null
Чего в не хватает types-spring?
types-spring не ставит перед собой целью сделать тайпскрипт идеальным, но, по крайней мере, он пытается сделать его лучше и безопаснее.
В этой статье я рассмотрел далеко не все возможности этого пакета: помимо патчей типов он содержит некоторые полезные utility types, которые могут импортированы в проект и существенно облегчить жизнь рядовому разработчику (если эта статья зайдет, то, возможно, напишу следующий обзор в таком же духе про utility types types-spring на фоне типов types-fest).
Возможно, вам кажется, что что-то в представленных патчах не так и какие-то из них, на ваш взгляд, делают код менее безопасным - если так, скажите об этом в комментариях. У types-spring
есть отдельная unsafe branch, куда вносятся те патчи, которые вызывают сомнения в типобезопасности. Это, например, касается известного Object.keys: чтобы Object.keys
возвращал Array<keyof T>
вместо string[]
- достаточно выполнить npm i -D Sanshain/types-spring#unsafe
и добавить ссылку на пакет согласно инструкции., однако ответственность за использование небезопасных патчей придется взять на себя.
Что выбрать: ts-reset или types-spring?
Итог: ts-reset
предлагает базовые патчи ecmascript, которые делают разработку на typescript более безопасной, types-spring
- дополняет патчами типов для Document Object Model. Почему бы не использовать их совместно, если они не конфликтуют друг с другом (а этому уделяется особое внимание)?
А что думаете вы по этому поводу? Используете ли подобные патчи в своих проектах?
Amareis
Напомню что target - это изначальный источник события, а currentTarget - текущий. Разница между ними в том, что тот же click мог быть инициирован на одном из вложенных элементов, а обрабатывать его мы можем гораздо выше по дереву. Такой сценарий никоим образом отработать на уровне типов не получится, поэтому мы можем быть точно уверены только в типе curentTarget (как сделано например и в реакте).