Typescript не идеален. Его ругают, но любят. Кто‑то даже не может представить свою жизнь без него так же, как не может представить жизнь без комфортного автомобиля. Тем не менее, у этого «автомобиля» в базовой комплектации есть существенные недостатки, которые каждый «автолюбитель» «чинит» по своему.

Один мой знакомый сравнил тайпскрипт с css браузеров, которому необходим свой собственный аналог css reset. И оказалось, что такой действительно есть. Речь идет о пакете, название которого говорит само за себя - ts-reset. За полгода своего существования на github ts-reset набрал 6 тысяч звезд, и мне показалось странным, что на Хабре я не нашел ни одной статьи, посвященной этому пакету. И если интересно, добро пожаловать под кат...

ts-reset

Что оно нам дает?

Начну с того, что у ts-reset прекрасная документация, которая помещается в один readme файл, что копировать оттуда все примеры его использования с «до» и «после» вижу избыточным. Поэтому пройдусь по ключевым моментам: все патчи типов, которые предоставляет ts-reset условно можно разделить на три группы:

  1. те, которые делают проверку типов более строгой и как следствие более безопасной - в основном это замена 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[]
  }
};
  1. те, которые делают проверку типов более мягкой и как следствие более удобной (для разработчиков) так, чтобы не сделать код менее типобезопасным. В основном такие патчи касаются смягчения сигнатур методов константных типов. Например, как с 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)
  1. те, которые просто делают 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. Почему бы не использовать их совместно, если они не конфликтуют друг с другом (а этому уделяется особое внимание)?

А что думаете вы по этому поводу? Используете ли подобные патчи в своих проектах?

Комментарии (1)


  1. Amareis
    08.06.2023 10:50

    можно твердо утверждать, что если событие пользовательского интерфейса вызвано не искусственно (является isTrusted), то target будет являться как минимум Node. Если же искусственно, то оно будет идентично currentTarget. (просьба поправить (ну или привести пример, доказывающий обратное), если в этих рассуждениях есть прокол).

    Напомню что target - это изначальный источник события, а currentTarget - текущий. Разница между ними в том, что тот же click мог быть инициирован на одном из вложенных элементов, а обрабатывать его мы можем гораздо выше по дереву. Такой сценарий никоим образом отработать на уровне типов не получится, поэтому мы можем быть точно уверены только в типе curentTarget (как сделано например и в реакте).