A simple way to implement protected

I've been working on a non-decorator implementation of this idea. It turns out that overriding parent accessors is a real issue. The only way I can think of doing it is to have the actual protected structure owned by the instance live outside the instance, and have each class initialize its own protected access as accessors referencing that external protected structure. I had to so something similar to this with my ClassicJS library.

The result is that even the owning class will only get accessors to the protected fields. Meanwhile, each derived class will add its own protected member prototype to the top of the external protected structure's prototype chain. This will at least give us a way to access protected supermethods. I think this approach might also resolve the issue I ran into with decorators last time. I'll try that once I get my protected library working as a proof of concept.

I'm not sure I followed what the problem is, or at least how it's different from what I did earlier. In the example I first gave, I ended up with each level in the inheritance chain owning its own #protected proxy object which mixed accessors for both the protected properties of the local class instance, as well as the parent. That does mean a derived class redefining a protected property would in effect shadow it, and the ancestors would keep seeing their own version unaffected by the derived one. I didn't bother trying to setup a prototype chain for that protected proxy, but I probably could have (it should allow a manual access to the super's protected object accessors, e.g. this.#protected.__proto__.shadowed)

That's the issue I was describing. That's a problem for anyone expecting the usual behavior of protected properties from this. There's also the issue of this binding against the accessor methods. I managed to come up with a solution. First step is to create a common object containing the complete set of protected members. That means moving the protected members off of the owner and replacing them with accessors just like in descendant. If we then allow any accessor defined for the owner to be moved to the accessor property object, it can then be overriden by derived classes like normal.

1 Like

Given the route you all are considering for exposing private fields to other classes, I wouldn't be surprised if that reserved word gets unreserved.

Alternatively, you could just turn that into the name of a global decorator function and call it a day.

1 Like

I've created a small library based on this implementation along with a few other related utility functions. It shouldn't be too hard for someone who understands Babel internals to add some syntax for this if we can settle on what that should look like.

That's what I did in lowclass a while back (separate protected prototype chain, including compatibility with super within protected methods). Did you have a chance to fully inspect the implementation? Here are all the tests/examples:


If we were to base protected on private fields (like in the OP) it will be highly unusable. private fields are highly unusable already: I've has to revert from #foo to __foo many times in order to denote a property as private, and that'll break protected functionality as well (or rather, the fallback to __protected will not be possible because it will be hidden away behind syntax).

I've has to revert from #foo to __foo many times in order to denote a property as private

Why have you had to do this? If you needed to access the property outside the class, then it's not really private to begin with, so I would think it's a good thing that you couldn't use the "#" syntax to denote it as private when it wasn't actually private. Perhaps, you were needing something like package-private, which JavaScript doesn't support?

I was only accessing the property inside the class, and the property is supposed to be private. Believe me, I would have marked the variable as just foo to mark it as public if I was using it outside the class. :)

See here for a problem example:



Random fun, but I've implemented "friends" or similar features here: https://github.com/trusktr/lowclass/blob/dbb10b0c97a6690aabf4549c46a12baa8bd19ed5/src/Class.ts

Unit tests for that here: https://github.com/trusktr/lowclass/blob/dbb10b0c97a6690aabf4549c46a12baa8bd19ed5/src/tests/friends.test.js

1 Like

@trusktr There's a few differences betwee what lowclass does and what I've done with CFProtected. For instance:

import { share, abstract, saveSelf, accessor } from "/node_modules/cfprotected/index.mjs";

const TagBase = abstract(class TagBase extends HTMLElement {
    static #themeManager = null;
    static #registered = [];
    static #ready = false;
    static #renderQueue = [];
    static #sprot = share(this, {
        registerTag: tag => {
            saveSelf(tag, "pvt");  //Convenience for the derived classes
            console.log(`registerTag: defining element '${tag.tagName}' using class '${tag.name}'`);
            this.pvt.#registered.push({name:tag.tagName, tag});
        },

        initTags(tm) {
            this.pvt.#themeManager = tm;
            for (let entry of this.pvt.#registered) {
                customElements.define(entry.name, entry.tag);
            }
        },

        isTagType(target, type) {
            let klass = target.cla$$
                ? [target.cla$$]
                : [HTMLElement, target.nodeName.toLowerCase()];

            while (klass[0] !== HTMLElement) {
                let k = klass[0];
                klass[0] = k.tagName;
                klass.unshift(Object.getPrototypeOf(k));
            }
            klass.shift();

            return klass.includes(type);
        },

        runReadyEvents() {
            if (!this.pvt.#ready) {
                this.pvt.#ready = true;
                for (let event of this.pvt.#renderQueue) {
                    let {obj, eventName, data} = event;
                    obj.fireEvent(eventName, data);
                }
            }
        }
    });

    static get observedAttributes() { return [ "theme", "style", "classList" ]; }

    static { saveSelf(this, "pvt"); }

    #listenerMap = new WeakMap();
    #sizeInfo = null;
    #areEventsReady = false;
    #deferredEvents = [];

    #tagError() {
        this.shadowRoot.innerHTML = "";
        this.shadowRoot.appendChild(this.pvt.#prot.newTag("h3", 
            {style:"background-color: red; color: yellow; font-weight: bold;"},
            {innerHTML: "ERROR!"}));
    }
    
    #sizeChanged(szInfo) {
        let sInfo = this.pvt.#sizeInfo;
        return !(sInfo && szInfo &&
            (szInfo.clientWidth === sInfo.clientWidth) &&
            (szInfo.clientHeight === sInfo.clientHeight) &&
            (szInfo.innerWidth === sInfo.innerWidth) &&
            (szInfo.innerHeight === sInfo.innerHeight) &&
            (szInfo.outerWidth === sInfo.outerWidth) &&
            (szInfo.outerHeight === sInfo.outerHeight) &&
            (szInfo.offsetWidth === sInfo.offsetWidth) &&
            (szInfo.offsetHeight === sInfo.offsetHeight));
    }

    #eventsReady() {
        this.pvt.#areEventsReady = true;
        for (let e of this.pvt.#deferredEvents) {
            this.attributeChangedCallback(e.name, e.oldVal, e.newVal);
        }
    }

    #prot = share(this, TagBase, {
        shadow: null,

        newTag(tag, attributes, properties) {
            let retval = document.createElement(tag);
            if (attributes && (typeof(attributes) == "object")) {
                for (let key in attributes) {
                    retval.setAttribute(key, attributes[key]);
                }
            }
            if (properties && (typeof(properties) == "object")) {
                for (let key in properties) {
                    switch (key) {
                        case "children":
                            for (let child of children) {
                                retval.appendChild(child);
                            }
                            break;
                        case "parent":
                            properties[key].appendChild(retval);
                            break;
                        default:
                            retval[key] = properties[key];
                    }
                }
            }
            return retval;
        },
        toCamelCase(str) {
            return String(str).replace(/-(\w)/g, (_, letter) => letter.toUpperCase());
        },
        encodeHTML(str) {
            return String(str).replace(/&/g, '&')
                              .replace(/</g, '&lt;')
                              .replace(/>/g, '&gt;')
                              .replace(/"/g, '&quot;');
        },
        renderContent(content, target) {
            this.fireEvent("preRender");
            let link, shadow = target || this.shadowRoot;
            if (target !== shadow) {
                link = TagBase.pvt.#themeManager.getTagStyle(this.cla$$.tagName);
            }
            if (!Array.isArray(content)) {
                content = [content];
            }
            shadow.innerHTML = link || "";
            for (let element of content) {
                if (typeof(element) == "string") {
                    shadow.innerHTML += element;
                }
                else {
                    shadow.appendChild(element);
                }
            }
            this.fireEvent("postRender");
            if (target !== shadow) {
                this.pvt.#prot.childrenResized();
            }
        },
        setBoolAttribute(attr, val) {
            if (!!val) {
                this.setAttribute(attr, "");
            }
            else {
                this.removeAttribute(attr);
            }
        },
        validateChildren(type, message) {
            if (typeof(type) == "string") {
                type = [type];
            }
            for (let child of this.children) {
                let found = false;
                for (let t of type) {
                    found |= TagBase.pvt.#sprot.isTagType(child, t);
                }

                if (!found) {
                    this.pvt.#tagError();
                    throw new TypeError(message);
                }
            }
        },
        validateParent(type, message) {
            if (typeof(type) == "string") {
                type = [type];
            }

            let parent = this.parentElement;
            let found = false;
            for (let t of type) {
                found |= TagBase.pvt.#sprot.isTagType(parent, t);
            }

            if (!found) {
                this.pvt.#tagError();
                throw new TypeError(message);
            }
        },
        childrenResized() {
            for (let child of this.children) {
                if ("fireEvent" in child)
                    child.fireEvent("parentResized");
            }
        },
        onParentResized() {
            let szInfo = {
                clientWidth: this.clientWidth,
                clientHeight: this.clientHeight,
                innerWidth: this.innerWidth,
                innerHeight: this.innerHeight,
                outerWidth: this.outerWidth,
                outerHeight: this.outerHeight,
                offsetWidth: this.offsetWidth,
                offsetHeight: this.offsetHeight
            }

            if (this.pvt.#sizeChanged(szInfo)) {
                this.pvt.#sizeInfo = szInfo;
                this.fireEvent("resized");
                this.pvt.#prot.childrenResized();
            }
        },
        render() {
            throw new TypeError(`The protected "render" method must be overridden`);
        }
    });

    constructor(options = {}) {
        super();
        saveSelf(this, "pvt", new.target);
        if (!("mode" in options)) {
            options.mode = "open";
        }
        this.attachShadow(options);
    }

    attributeChangedCallback(name, oldVal, newVal) {
        if (this.pvt.#areEventsReady) {
            this.fireEvent(`${this.pvt.#prot.toCamelCase(name)}Changed`, { oldVal, newVal });
        }
        else {
            this.pvt.#deferredEvents.push({name, oldVal, newVal});
        }
    }

    connectedCallback() {
        this.addEventListener("render", this.pvt.#prot.render); 
        this.addEventListener("parentResized", this.pvt.#prot.onParentResized); 
        this.pvt.#eventsReady();
        this.fireEvent("render");
    }

    fireEvent(evtName, data) {
        if (TagBase.pvt.#ready) {
            let event = new CustomEvent(evtName, { detail: data });
            this.dispatchEvent(event);
        }
        else  {
            TagBase.pvt.#renderQueue.push({obj:this, eventName:evtName, data});
        }
    }

    addEventListener(name, fn) {
        let mapping = this.pvt.#listenerMap.get(fn) || { count: 0, name, boundFn:fn };
        ++mapping.count;
        this.pvt.#listenerMap.set(fn, mapping);

        if (mapping.count === 1)
            super.addEventListener(name, mapping.boundFn);
    }

    removeEventListener(name, fn) {
        let mapping = this.pvt.#listenerMap.get(fn);
        if (mapping && (mapping.name == name)) {
            --mapping.count;
            
            if (!mapping.count) {
                super.removeEventListener(name, mapping.boundFn);
                this.pvt.#listenerMap.delete(fn);
            }
        }
    }

    getBounds(relative, childBounds) {
        let retval = this.getBoundingClientRect();
        if (relative) {
            if (childBounds && (this !== app)) {
                retval.x += childBounds.x;
                retval.y += childBounds.y;
                retval.left += childBounds.left;
                retval.top += childBounds.top;
                retval.width = childBounds.width;
                retval.height = childBounds.height;
            }
            if (this === app) {
                retval = childBounds;
                retval.right = retval.left + retval.width;
                retval.bottom = retval.top + retval.height;
            }
            else {
                retval = this.parentElement.getBounds(retval);
            }
        }
        return retval;
    }

    get theme() { return this.getAttribute("theme"); }
    set theme(val) { this.setAttribute("theme", val); }
});

export default TagBase;

This is actual working code using CFProtected. Where you see this.pvt.#prot.something is where I'm accessing protected fields. Remember the rules I stated in the OP? Where I use .pvt, I'm working around the user Proxy problem. The classes that extend this code won't have any issues due to Proxy wrapping except where there are problems in HTMLElement. All cases of .#prot or .#sprot are accesses to the protected container.

Now as for the differences between lowclass and CFProtected:

  • Wherever possible, I'm not requiring anything additional over normal class declarations.
  • Base protected members overridden in a derived class access the derived class version in the base methods, but not in the base constructor. There's no straight forward way of working around that limitation.

So when it comes down to it, I never have to make protected properties public. I've been considering adding a way to defer constructor code execution until after all constructors have applied their private fields. No matter how I think about it, the signature would be function(_this, _klass, _newTarget, fn) where klass is the current class constructor and fn is a function with the constructor logic in it.

The idea is that fn in each constructor would get thrown on a queue. The queued functions wouldn't get executed until _klass === _newTarget. That would keep all the constructor logic in the constructor while guaranteeing that a) the private and protected members are already all set up before any constructor logic happens, and b) all constructor logic is completed before the object is returned to the caller of new.

What do you mean by this?

For the issue with creating protected properties that override those of the base class, would it potentially be simpler to implement if there were two separate decorators? One for defining a new protected field, and one for overriding an existing one? Say, @protected and @overrideProtected. Perhaps it's simpler to make @protected throw an error if it detects it would shadow an inherited field, then trying to make it handle both scenarios, dunno.

Plus, it's arguably nicer to have that distinction when reading code, to know that a protected field is a new one vs an overwritten one.

I mean, with the exception of dealing with the constructor, all inheritance works exactly how you would expect, including that protected members can be overridden in derived classes, and those overrides can be used by base classes.

IMO, no. It would be more clear to the original developer and the reader, but marginally harder to implement. If you look into the code base, you'll notice I'm just doing the same thing C++ does when dealing with virtual functions. I just make sure that the approach I take is consistent with the behavior of public members, but without leaking unknown protected members to base classes.

Can this be done cleanly on the new stage 3 decorators? I wonder what the impl would look like.

class MyClass {
  @protected foo = 123
}

class MyOtherClass extends MyClass {
  test() {
    console.log(this.foo) // it works.
  }
}

new MyOtherClass().foo // undefined or error

EDIT: Due to no class access in field decorators, I think this would require a class decorator too:

@withProtected
class MyClass {
  @protected foo = 123
}

I think the fields need to be accessors too, at least when I tried this before

I suppose theoretically the class decorator can install prototype properties if those are needed, and delete fields in the subclass.

Yeah, I can't fully remember my thought process from back then. It may have been due to composition, using other decorators on the "protected" fields would receive false info if the field was only secretly an accessor, as opposed to explicitly one.