TypeScript: Расширьте Object.prototype и ссылайтесь на this

Вопрос или проблема

Сначала фрагмент кода, который я пытаюсь заставить работать:

// взято с 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.

Из-за влияния на глобальную область видимости применение таких методик должно использоваться с осторожностью. Всегда получайте согласие команды и, по возможности, выбирайте альтернативные решения, такие как утилитарные функции или классы.

Оцените материал
Добавить комментарий

Капча загружается...