Этот вопрос интересовал меня настолько давно, что за прошедшие годы даже стал как-то про него забывать. И не то, чтобы меня это прям как-то сильно интересовало, но всё же лучше понимать назначение было бы приятно.

UPD, вот для чего:

const Cstr = function () {
    const Self = function () {};
    Object.setPrototypeOf(Self, this);
    return Self;
};

const item = new Cstr;

console.log('item instanceof Cstr : ', item instanceof Cstr);

const itemInstance = new item;

console.log('itemInstance instanceof item : ', itemInstance instanceof item);

А тут есть GIST, где кода и возможностей намного больше.

Дальше читать на свой страх и риск ... много букв, смысл тот же.

Недавно нашёл эту интересную особенность, которую реализовать иначе совсем вот вообще никак нельзя, только так, и никак иначе. И, да, она с самых ранних версий существует, и да, более того, в последние годы получила широкое развитие. Только когда я это осознал, то стал абсолютно уверен, что никто это так не использует, и поэтому решил написать статью, чтобы и вам всем это стало известно. Такой вот альтруизм, вдруг случится что, а так хоть статя эта останется. И ладно, что давно не пишу на хабр, все, кому это нужно тут прочтут и разберутся, может быть, когда-нибудь.

Как это было ... Да, как обычно, самое главное -- это сформулировать вопрос. Без правильного вопроса не будет ответа. Наш вопрос уже вроде бы поставлен, переформулируем ещё раз:
-- Существует ли в JavaScript какой-либо паттерн кода, который невозможно сделать иначе, чем использовать return в конструкторе?

-- Но, позвольте, а что вообще мы можем из конструктора вернуть?
-- Мы можем вернуть объекты, и на этом, в принципе, всё, вместо примитивов возвращаются экземпляры, возвратная операция для примитивов не осуществляется.

То есть нужно найти какой-нибудь такой объект, возврат которого вместо this имеет конкретный практический смысл, при этом Иначе это сделать невозможно.

Что же это может быть? По сути всё, что может конструктор вернуть кроме this, можно сделать "иначе", просто создав этот объект. Зачем тогда вообще использовать конструкторы? Понятно, что, например, классы -- это удобно. Но есть ещё функциональные конструкторы, которые от function, они исторически существуют и были, и тоже вполне себе полноценны, их даже можно использовать для class extends, например:

const MyFn = function () {
	this.prop = 123;
}

class MyClass extends MyFn {}

const instance = new MyClass;

console.log('instance instanceof MyClass', instance instanceof MyClass);
console.log('instance instanceof MyFunction', instance instanceof MyFn);

console.log('instance .prop : ', instance.prop);

И вот, а для них была ли какая-нибудь такая функциональность, которую нельзя было иначе сделать? Тогда же ведь в спецификации ещё даже new.target не было, а return уже был, и вот "зачем" вообще это нужно, если мы всего лишь экземпляры создаём?

Хорошо, ладно, есть объекты. Мы можем вернуть любой объект.

-- А, что такое объект?
-- Да, в общем-то всё, что не примитив.

И, значит, все объекты можно, получается возвращать. Значит мы должны найти какой-то такой объект, который можно вернуть только при вызове функции-конструктора с оператором new и по-другому совсем никак нельзя вернуть, только именно, чтобы конструктор вернул этот самый такой своеобразный объект. Что это может быть? Какая такая странная сущность, нуждающаяся в операции конструирования, может обладать функциональностью, которую иначе получить нельзя?

Что нам известно?
Конструкторы возвращают экземпляры, которые на самом деле наследуют их свойство .prototype и они при этом дополнены всеми теми свойствами, которыми мы в конструкторе через this. положим в этот экземпляр. При этом ничего не мешает просто создать пустой объект и наполнить его теми же самыми свойствами без необходимости операции конструирования. Но этот объект будет обладать одним существенным минусом, он не будет instanceof этой самой функции. Конечно, вы, может быть скажете, что instanceof не нужен, и вообще, это всего лишь сравнение вида:

Object.getPrototypeOf(this).constructor === MyConstructor;

И, то есть, как бы да, мы это можем поменять, просто установив другой конструктор, именно поэтому сейчас появился Symbol.hasInstance, которым мы можем обогатить проверку instanceof для получения преимуществ перед наивным поведением. То есть -- мы можем, в целом, полагаться на то, что в самом деле instanceof сейчас уже можно создать таким, чтобы оно действительно ломалось в тех случаях, когда что-то сделано неверно. И, значит тезис о том, что мы можем нуждаться в проверке на то, что экземпляр является действительно наследником конкретного конструктора в целом верен, и "простым" способом получить это поведение можно только через оператор new.

Отсюда следует, что спектр исследуемых объектов может быть ограничен только действительными экземплярами-наследниками конструктора. Но, опять же, без этого можно обойтись, проверку на "вхождение" в "семейство" можно организовать как-то иначе. И по прежнему, существует ли какой-нибудь такой вид экземпляров, которому обязательно нужен returnчерез оператор new ?

Как будто со всеми объектами ситуация такая, что да, мы можем обойтись. Но есть такой особый вид объектов, который, если не рассматривать пристально, не кажется даже чем-то необычным. И вот недавно нашёл одну для него ситуацию, когда иначе никак нельзя:
-- Конструктор может вернуть Функцию function или Класс class.

Функции и Классы ведь тоже объекты. И тогда, значит, получается, что конструктор сам возвращает конструктор. Логично, что вы сразу спросите меня как быть с instanceof, ведь в этом случае мы его потеряем. Это действительно так, но нам ничто не мешает унаследовать эту функцию от this, и в этом случае, конечно же, она будет экземпляром исходного конструктора:

const Cstr = function () {
    const Self = function () {};
    Object.setPrototypeOf(Self, this);
    return Self;
};

const item = new Cstr;

console.log('item instanceof Cstr : ', item instanceof Cstr);

const itemInstance = new item;

console.log('itemInstance instanceof item : ', itemInstance instanceof item);

Безусловно, Object.setPrototypeOf не существовал в ранних версиях спецификации, и простой и внятный способ обойти это ограничение придумать сложно.

Но, то в целом на сегодняшний день -- это единственный способ получить экземпляр, который сам по себе является конструктором. Мы можем попробовать завернуть конструирование в Proxy, но с этим, увы, ничего не выйдет:

'use strict';

const Cstr = function() {
    const proxy = new Proxy(this, {
        construct(target, args) {
            return new target(...args);
        }
    });
    return proxy;
};

const instance = new Cstr;

console.log(instance instanceof Cstr);

try {
    new instance;
} catch (error) {
    console.error(error);
/*
TypeError: instance is not a constructor
    at Object.<anonymous> (/code/ConstructibleProxy.js:17:5)
    ...
*/
}

И на этом в принципе почти всё.

Дальше получаются ещё более интересные вещи. Например, что мы можем наследовать классы от экземпляров других классов. Кроме того мы можем "докрутить" проверку на new.target при вызове и использовать эти экземпляры как обычные функции.

Да, конечно, в случае с TypeScript придётся ему объяснять, что, мол, тут у нас конструктор возвращает интерфейс, а не тип, но как будто бы это уже и не самая сложная задачка.

Если интересно посмотреть на много кода, вот ссылка на Gist.

Там примерно 200 строк console.log со всеми возможными проверками и какая-то реализация для TypeScript с Generic'ами.

Итого ... просто оставлю это здесь, кому нужно тот найдёт.

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


  1. NetFantomIO
    20.08.2025 18:40

    Очень ждал что будет раскрыт какой-то прикладной смысл этого упражнении. Но так и не нашёл :(


    1. wentout Автор
      20.08.2025 18:40

      Рекурсивные конструторы и наследование от экземпляров -- разве не прикладной смысл ?
      Зачем они нужны, например:
      - наследование от экземпляров отражает развитие жизненного цикла
      - рекурсивные конструторы позволяют создавать зависимые типы
      Много чего можно придумать, если включить фантазию )


      1. NetFantomIO
        20.08.2025 18:40

        "Рекурсивные конструторы и наследование от экземпляров" звучит как инструмент или решение, но для каких прикладных задач? Для какой фичи? Можете привести пример "из жизни" когда это требуется?

        "Наследование от экземпляра" уже звучит несколько жутковато, и кажется результатом неудачной архитектуры.

        Я, возможно, чего-то не понимаю.


        1. Devoter
          20.08.2025 18:40

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


          1. wentout Автор
            20.08.2025 18:40

            Согласен, стиль описания старинный. Мало кода, много букв.
            Но я так вижу, мне так хотелось оставить. Поставил +1 комментарию )


        1. wentout Автор
          20.08.2025 18:40

          Попробую.
          Представьте себе, что Типы -- это данные, а Интерфейсы -- это Поведение, краткое описание алгоритма как чёрного ящика. Это простая логика, придуманная давным давно, до JS и до TS тем более. Если на код в таком понимании смотреть, то становится логичным иметь эти возможности.
          Конкретно, про оба тезиса:

          • Наследование от экземпляра даёт возможность хранить в этом экземпляре логику предыдущих итераций жизненного цикла данных. То есть, допустим, мы приняли порцию данных в Endpoint, это экземпляр данных "первого рода". Далее через какой-нибудь интерфейс -- по сути то есть любой метод и конструктор -- над этими данными произведена трансформация. Логично, что данные в этом случае "унаследованы" от экземпляра "первого рода". То есть, имея Тип исходных данных и Интерфейс их трансформации как конструктор мы можем этот конструктор унаследовать от экземпляра. Это и есть исходный способ работы прототипного насследования, как аппликативный функтор: конструктор применяется к существующим данным -- прототипу, -- и аргументам -- то есть новым данным для обогащения нового экземпляра.

          • "Рекурсивные конструкторы" -- это игра слов, тут тоже критика весьма конструктивна, т.к. совсем же ничего не пояснил, в самом деле.

            И, то есть, что здесь происходит, с учётом предыдущего объяснения про наследование. Конретно, представим ситуацию, что нам не нужно менять "модель", то есть не нужно менять саму "структуру" данных, но при этом необходимо получить новый экземпляр этих самых данных для какой-нибудь конкретной операции. То есть, допустим, у нас экземпляр второго рода -- приняли данные и сделали DTO -- и теперь мы хотим это DTO положить в лог или в БД. При этом структура данных у нас уже удовлетворительная, но нужен новый экземпляр, например для логов мы хотим установить в undefined значение поля password и нам структурно не нужно изменять объект DTO, но для этого конкретного поля нужно установить его значение через .definePropery в get () { return undefined;}для отражения назначения. В этом случае согласно SOLID мы же уже описали все поля ранее, в исходном DTO, и нам не нужен структурно иной объект т.к. поле password у нас string | undefined, но само значение мы пока не установили, и тут нам то есть нужен такой же самый тип, который является наследником своей предыдущей версии. И, получается, что нам для него не нужен новый конструктор, но информация о том, что это новая версия вполне может пригодиться для трейсинга и отслеживания ошибок. Аналогично с БД, нам лишь какой-то набор полей нужно изменить, не меняя структуру самого объекта -- это тоже то есть уже SOLID-но на типах. То есть это уже в Runtime так будет, а не только Ahead of Time. И тогда так же логично вполне становится понятно зачем абстрактные классы вообще существуют.

          Конечно, это всё антигуманно, и никто так делать не будет. Но так писать код в целом вполне возможно при наличии развитого инструментария, чтобы не выписывать весь boilerplate для этого вспомогательного кода, способный отразить такую "более строгую" логику работы с интерфейсами и типами, чем та, которая присутствует сегодня.

          И, да, предвижу вопросы про "а пробовал ли сам" и "что с Performance".
          Пробовал. С Performance, конечно, так себе.
          Только вот суть в том, что сам код радикально отличается от того, что я вижу в 99.9(9) % существующих проектов. Его кратно меньше, он иначе структурирован, т.к. логика развития данных и алгоритмов это "деревья" Trie из прошлого в будущее. То есть именно то, как оно должно быть, и ничего другого. Код получается более компактным и на этом возникает экономия -- глупостей меньше, оптимальностей болье. Чем-то напоминает ФП, но такое, "на стероидах", объектно-функциональное и, конечно, пространство для маневра в виде Аспектно Ориентированного Программирования, т.к. по сути во все замыкания легко добавить Аспекты -- то еть отражения текущего состояни процесса. И тогда, допустим, основываясь на предыдущем примере про пароль, для конструктора логов при откладке выставлен аспект переключен в "покажи пароль", и пароль отображается. Т.е., это могут быть realtime изменения, без перезагрузки и т.п.

          Безусловно, всё это можно реализовать иначе. Через спагетти-макароны от и DI/IoC-контейнеров, где "привычка" раскладывать всё именно так порождает простыни import-ов для кратно меньшего объёма самого кода -- это то, что я вижу в Nest.js и т.п. Возможно просто проекты не удачные. Но, блин -- это сложно, иметь столько "расписаний" каждый раз на каждый чих. Можно иначе, исходно оно выглядит намного сложней, но в итоге получается сильно проще.


    1. JohnSutray
      20.08.2025 18:40

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

      Еще в голову приходит идея возвращать наследников текущего класса на основе передаваемых параметров. Да, это немножко "не по правилам ооп", но мне кажется довольно удобным решением, когда в базовый класс ты передаешь параметры, а он уже сам решает, что создать.


  1. aamonster
    20.08.2025 18:40

    Задаём вопрос ChatGPT, получаем более краткое и понятное разъяснение.

    Но в общем и целом этот функционал не нужен, за его использование надо бить по рукам. Вместо него следует использовать factory function.


    1. wentout Автор
      20.08.2025 18:40

      Вы сейчас серьёзно ?
      Конструктор -- это и есть фабрика.
      В случае конструкторов, создающих объект-конструктор -- это фабрика фабрик.
      Только "настоящая", с проверкой на instanceof в отличие от того, к чему вы наивно привыкли слушая свои нейросети, которые ничего подобного не видели и не напишут вам, я проверял -- они пока не знают, что так можно, потому, что этого кода никто никогда не писал.


  1. muhachev
    20.08.2025 18:40

    Если отжать от тонны воды, останется примерно следующее:

    В JavaScript конструктор может вернуть объект вместо this, и обычно это избыточно, но есть один уникальный случай: он может вернуть функцию или класс, то есть другой конструктор. Это позволяет создавать рекурсивные конструкторы и наследовать от экземпляров - то, что по-другому реализовать нельзя. Всё остальное использование return в конструкторах можно заменить фабриками или обычным кодом.


    1. unreal_undead2
      20.08.2025 18:40

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


      1. wentout Автор
        20.08.2025 18:40

        Не уверен, что понял, про что речь.
        Вы имеете в виду Type Families, где все классы являются наследниками одного исходного экземпляра ? https://en.wikipedia.org/wiki/Type_family


        1. unreal_undead2
          20.08.2025 18:40

          В JS же вроде duck typing - можно возвращать объекты никак не связанных классов, достаточно чтобы они реализовывали общие интерфейсы.


          1. wentout Автор
            20.08.2025 18:40

            Ааа, вы про это. Да, есть такое ограничение для прямых указателей, т.е. в JS пока нет перегрузки операции Assign.
            Но если работать с полями объектов через get-еры/set-еры и добавить чуточку магии мета-программирования для Symbol.toPrimitive, то всю TS логику ограничений на типы можно примерно в 50 строк компактненько добавить в Runtime, т.е. добавить строгость и ограничения прям в туда. Таким образом на TS логика не изменится, а логика JS в Runtime'е будет ей соответствовать.
            С учётом того, что, например IEEE 754 в целом отражает состояние вне зависимости от языка -- это особенно полезно будет запрещать творить дичь, складывать примитивы с объектами и т.п.. В общем строгая типизация со всеми её плюсами и минусами для полей объектов вполне возможна.
            То есть мы можем сделать так, что в полях объектов для Данных хранятся Объекты. И то есть можно получить вполне себе Номинальную Типизацию. Но да, только для полей объектов. Но не очень сложно ведь. И объяснение "как" простое -- через Symbol.hasInstance проверять является ли экземпляр наследником класса MyNumber, и, таким образом всё становится намного интересней, веселей и действительно гибким, гораздо более динамическим чем исходный такой весь из себя "динамический" JS.
            И вот тут уже становятся весьма необходимыми все эти преимущества языков, считающихся более развитыми, т.к. мы в самом деле уже давно можем это не просто смоделировать, а более того -- смоделировать так, как нам нужно.
            Т.е., такой "дизайнерский" JavaScript под задачи конкретного проекта. Нужна нам, допустим, System F -- да пожалуйста. Нужна λC -- да тоже без проблем, конечно, забирайте, грузите вагонами: https://en.wikipedia.org/wiki/Lambda_cube


            1. unreal_undead2
              20.08.2025 18:40

              Как то всё сложно. Я к такому примеру - сначала создали конкретный класс, который используется в куче мест (естественно, никакого прямого доступа к полям объектов, только чётко документированный интерфейс - но создаётся везде просто через конструктор с аргументами). Потом получилось, что для какого то набора аргументов реализация заметно отличается - сделали отдельный класс, в конструкторе первоначального класса при необходимости возвращаем его объект; код пользователей менять не надо - они продолжают считать, что работают с одним конкретным классом.


              1. wentout Автор
                20.08.2025 18:40

                Совершенно верно. Это то, как есть сейчас. С этой логикой нет никаких существенных проблем. Её обычно на всё хватает. Этим пользуются, к тому же есть private модификаторы в самом JS уже тоже.

                Просто вы описываете Назначение Экземпляра.

                Попытка расширить Назначение до Жизненного Цикла -- это и есть всё, что дальше.
                И с этим сложности вполне существенные: жизненный цикл данных в JS имеет приоритет над назначением самих данных, это в самом дизайне языка, само прототипное наследование -- это оно и есть, приоритет жизненного цикла над назначением через наследование от экземпляра. И поэтому язык динамический, поэтому мутации приводили и продолжают приводить к ошибкам -- потому, что не совсем объясненно как их делать так, чтобы этого не случалось. И, даже больше того, ранние версии языка почти совсем ничего в эту сторону не позволяли: метапрограммирования в принципе не было, как структуры языка, про возможность придать больше строгости тоже речь не шла.

                Сейчас это всё появилось, и можно об этом задумываться, а можно не задумываться. Существующая теория язвков программирования в целом отражает большинство аспектов о том "как задумываться". Только вот для JS мало примеров практической реализации, т.к. нужно самим "сконструировать" всё то, что уже есть в теории и в других языках, её отражающих. Пример из статьи -- всего лишь один из кирпичиков. Тут в комментариях, получается, ещё парочка кирпичиков. И можно посмотреть по интернетам, в принципе почти все нужные кирпичики есть. Т.е., сегодняшний JS -- это конструктор языка, писать на нём "в чистом виде как есть" -- тоже можно. Но можно писать иначе, создав себе всё необходимое под конкретный проект. Наверное с этого комментария стоило бы начать статью, но, увы, я был в контексте своих мыслей, мне просто хотелось показать код, оставить его на сохранение.


          1. JustAReader
            20.08.2025 18:40

            В JS ведь нет интерфейсов. TS всё таки попытка ограничить JS чтобы это более менее просто проверять типы. Интерфейс это неплохо, да и можно использовать хитрый union тип или Record вместо простого интерфейса, но это все-таки далёкая аппроксимация динамической природы JS. Если бы TS хотел отразить эту динамичность, то ИМХО тип должен изменяться вместе с изменением объекта.


            1. wentout Автор
              20.08.2025 18:40

              да, и это достаточно просто объяснить TypeScript-у, буквально одна строка

              export type Proto<P, T> = Pick<P, Exclude<keyof P, keyof T>> & T;

              здесь, получается, что мы заменяем в прототипе типа те свойства, которые определены в самом порождаемом типе, то есть "переопределены"


            1. unreal_undead2
              20.08.2025 18:40

              На уровне языка нет, но можно в рамках проекта договориться, что с таким то объектами работает только через такие то методы с такими то параметрами.


    1. wentout Автор
      20.08.2025 18:40

      да, примерно так )
      мне хотелось отразить муки творчества )
      но, да, +1 к комментарию )


      1. muhachev
        20.08.2025 18:40

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


        1. wentout Автор
          20.08.2025 18:40

          да, согласен, учту, мои "оправдания" никому не интересны, всем всё равно, у всех своих хватает страданий и мыслей и т.п. +1 коментарию.

          чуть поменял статью для читателя, но, наверное уже поздно, да и ладно

          но, согласитесь, что категорично делить на "чёрное и белое" в целом тоже, не отражает позицию большинства, возможно просто вы не моя Целевая Аудитория, кому-то "художественный свист" вполне приемлем, и можно было просто не читать буквы и смотреть сразу на код )

          Так что, категорично объяснять как себя "вести в приличном обществе" -- оно, конечно, безусловно -- вполне можно, но вести или нет -- это выбор персонажа.

          Так а про сам результат что скажете ? Стиль понятно, страдает. Результат вам как, понравился? Или вы это раньше уже видели?


  1. Alexandroppolus
    20.08.2025 18:40

    Имхо, фича может быть полезна, если например был обычный класс (экземпляры которого создавались просто через new в куче мест кода), и вдруг превратился в синглтон, мультитон или объектный пул, в общем, с возвратом существующего объекта в некоторых кейсах. Можно слегка подправить конструктор и больше нигде ничего не менять.


    1. wentout Автор
      20.08.2025 18:40

      да, тут же в примере не описаны аргументы, так как это фабрика фабрик, то по сути мы можем конструировать разные фабрики в зависимости от аргументов, но при этом ещё дополнительно можем проверить, что они instanceof самой фабрики.