Вопрос или проблема
Сначала фрагмент кода, который я пытаюсь заставить работать:
// взято с https://github.com/TheDavidDelta/scope-extensions-js и https://stackoverflow.com/a/65808350/2080707
declare global {
/**
* let вызывает указанный блок функции с значением `this` в качестве аргумента и возвращает его результат
* @param fn - Функция, которая будет выполнена с `this` в качестве аргумента
* @returns Результат `fn`
*/
interface Object {
let<R>(fn: (val: this) => R): R;
}
}
Object.defineProperty(Object.prototype, 'let', {
value(fn) {
return fn(this);
},
configurable: true,
writable: true,
});
А теперь то, что я пытаюсь достичь:
({ a: 'foobar' }).let((obj) => console.debug(obj.a))
Конечно, этот пример вымышленный и не имеет особого смысла. Но бывают случаи, когда это имеет смысл.
Моя проблема в том, что аргумент let имеет тип object
, а не тип {a:string}
.
Для этого мне нужно ссылаться на объект, прототип которого был расширен, а не на сам объект.
Любая подсказка была бы полезна :pray:
Правка: Можно адаптировать https://github.com/HerbLuo/babel-plugin-kotlish-also, чтобы тоже поддерживать let
. Но это, я полагаю, создает много накладных расходов на функции.
С самого начала, как вы знаете, считается плохой практикой расширять объекты-прототипы таким образом. Я не буду углубляться в это, но было бы неуместно, если бы я не призвал официально не использовать эту технику в любом производственном коде.
С этим покончено: вы пытаетесь использовать полифоморный тип this
, чтобы представить фактический тип объекта, на котором вы вызываете метод let()
, который почти наверняка будет уже, чем Object
.
К сожалению, как вы видели, компилятор с готовностью оценивает this
как Object
, когда вы вызываете let()
. Я не нашел авторитетной документации, которая говорит, что это происходит или почему (хотя я почти уверен, что видел что-то подобное раньше; я продолжу искать и обновлю, если найду это), но этот комментарий в microsoft/TypeScript#40704 вызывает размышления: this
типы являются дорогими. Они в сущности являются неявными обобщенными параметрами типов, и всякий раз, когда вы используете один из них в классе или интерфейсе, это накладывает штраф на производительность компилятора. Я подозреваю, что нативные типы, такие как Object
, не рассматриваются как потенциально дополнительные обобщенные, так как это повлияло бы на все типы объектов повсюду, и штраф был бы значительным.
К счастью, мы можем обойти это ограничение. Вместо того, чтобы использовать this
в качестве неявного обобщенного, мы можем создать явное обобщенное T
и использовать this
параметр типа T
в методе let()
:
interface Object {
let<R, T>(this: T, fn: (val: T) => R): R;
}
({ a: 'foobar' }).let((obj) => console.debug(obj.a)) // теперь все в порядке
Я впервые увидел такую технику в комментарии к microsoft/TypeScript#5863, чтобы обойти аналогичное ограничение, когда полифоморные this
типы недоступны внутри static
методов класса.
Обратите внимание, что тот же комментарий ранее в microsoft/TypeScript#40704 на самом деле говорит, что this
типы дорогие, но this
параметры дешевы. Поэтому, предположительно, это обходное решение приемлемо из-за таких “экономических” факторов.
Ответ или решение
Расширение прототипа объекта в TypeScript, как и в JavaScript, может привести к неоднозначным последствиям, поэтому важно подходить к данному вопросу с осознанием потенциальных рисков и недостатков. В данном ответе мы рассмотрим, как реально реализовать метод let
, который позволяет передавать текущий объект в качестве контекста вызова, и при этом сохранив типизацию.
Проблема с типизацией
Ваш первоначальный код:
interface Object {
let<R>(fn: (val: this) => R): R;
}
вызовет ошибку, так как компилятор TypeScript интерпретирует this
как тип Object
, что не позволяет нам получить доступ к конкретным свойствам объекта, таким как { a: string }
. Это связано с тем, что при расширении интерфейсов встроенных объектов, таких как Object
, TypeScript ограничивает использование this
.
Решение с использованием дженериков
Чтобы обойти эту проблему, мы можем использовать явные дженерики. Вместо использования параметра this
как неявного типа, мы определим его явно:
declare global {
interface Object {
let<R, T>(this: T, fn: (val: T) => R): R;
}
}
Object.defineProperty(Object.prototype, 'let', {
value(fn) {
return fn(this);
},
configurable: true,
writable: true,
});
Теперь метод let
принимает два параметра: R
(тип возвращаемого значения) и T
(тип объекта, на котором вызывается метод). Мы используем this: T
для того, чтобы явно указать, что текущий контекст вызова является типа T
.
Пример использования
Теперь вы можете использовать метод let
без проблем с типизацией:
const result = ({ a: 'foobar' }).let((obj) => {
console.debug(obj.a); // Здесь obj будет иметь тип { a: string }
return obj.a; // Можно вернуть результат с тем же типом
});
Эта конструкция позволит вам передавать текущий объект в функцию, сохраняя его типизацию и избегая проблем, связанных с использованием типа Object
.
Итог
Хоть и расширение прототипов является спорным подходом, корректная реализация с использованием явных дженериков позволяет достичь желаемого поведения в TypeScript, обеспечивая при этом безопасность типизации. Однако стоит помнить, что такие практики требуют осторожности и понимания возможных последствий в рамках поддержки вашего кода и его взаимодействия с экосистемой JavaScript.
Из-за влияния на глобальную область видимости применение таких методик должно использоваться с осторожностью. Всегда получайте согласие команды и, по возможности, выбирайте альтернативные решения, такие как утилитарные функции или классы.