Final classes

Problem statement

The "fragile base" problem states that, if you're not careful about how you modify a base class, you could accidentally introduce breaking changes to your public API. For this reason, many Java developers will declare all of their classes as final, except those that they're intentionally want you to inherit from. This gives maintainers more freedom with how they maintain their final classes, since they don't have to worry about the fragile base problem for any of these classes. Unfortunately, in JavaScript, all classes are pretty much non-final (there are some verbose tricks that can be employed to discourage inheritance, but most people don't bother with such tactics). This means, you must keep principles like the following in mind, if you don't want to make breaking changes to your public API:

  • If you have a method calling another public method, you must consider that as part of your public API. You can not refactor this to use a private method instead, or change how it calls the public method, because you don't know if someone else is going to try and override your public method.

  • Not strictly related to the fragile base problem, but, because your public methods may not be designed to be overridden, if an end-user overrides those methods, it could result in subtle bugs happening. The end-user may not even be intentionally overriding a method, they could be trying to add a new method, but they accidentally choose a bad name.

It turns out that inheritance isn't the only way to run into the maintainability issues associated with the fragile base problem. You can also bump into it by, for example, having a consumer of your class calling your methods via .call(), and using their own special "this" values, or, by mutating your class. In either of these cases, they would be able to construct code that's capable of depending on how your methods call other public class methods. I'm not overly worried about the class-mutation issue, since that can trivially be solved today with a couple of lines of code by freezing the prototype and preventing extension on the instance, though, it could be nice to find a way to incorporate it in. The issue about .call() is still important - it would be nice to find a way to handle that as well.

(The above is very high-level, if any examples are needed to help explain these issues, I could put some together).

So, to summarize, I'd like a not-overly-verbose way to discourage or prevent specific behavior(s), like inheritance, on my class, such that, as a maintainer of that class, I don't have to worry about the fragile base problem.

Potential Solution

I'd like to propose the addition of the "final" keyword, which we can stick before the "class" keyword to make it a "final" class. I'm choosing the term "final", because it achieves a similar goal as Java's "final", however, this doesn't mean that the keyword's behavior needs to be the same. If we must, we can always switch to using a different, made-up keyword instead if we think "final" would be too confusing.

As for how we need to go about doing the actual implementation of "final", I see the issue around .call() on methods as the root issue. If we can fix this, then the inheritance issue sort-of clicks into place. We can fix the .call() issue, by simply having all methods in a final class automatically do a bit of verification whenever they get called, to make sure the "this" parameter they're receiving is a direct instance of the class (direct == not down an inheritance chain), i.e. there's an assertion that makes sure that Object.getPrototypeOf(this) === TheFinalClass.prototype.

If that restriction, alone, is in place, then it's technically useless for someone to try and inherit from a final class. They could, but if they tried to use any of the inherited methods, they'd just get an error. That being said, it could still be nice to add some other, additional features - none of these are strictly required to solve the original problem statement, but if we can pull them off, it does make the "final" keyword more user-friendly.

  • It's a runtime error if you try to use the class syntax to extend a final class

  • It's a runtime error if you try to ever put a final class's prototype somewhere in a prototype chain. This is a fundamental change to how prototypes work, but right now, I can't think of any particular reason why this change would be bad.

  • We could auto-freeze the prototype, and auto-prevent-extension on the instance. The prevent extension action could either happen right after the constructor runs, or we could require you to declare all needed fields via public fields, and the prevent extension happens before-hand. I think it could be appropriate to make the "final" keyword do this sort of task in addition to preventing inheritance - the idea behind "final" is that the class is in its "final" state, you can't go modifying it, or overriding behaviors, or anything.

2 Likes

Final (sealed) keyword on classes ( and methods? ) make sense to me. Oddly, there's been clamoring for and denial of it in typescript for many years : Prevent class or its methods from overriding. With keyword like `final` or `sealed`. ยท Issue #50532 ยท microsoft/TypeScript ยท GitHub

Could this be accomplished with a class decorator that modifies the methods on the prototype to add this check?

1 Like

Yep. A final decorator would be something like this:

function final(klass, context) {
    Object.freeze(klass);
    context.addInitializer(function () {
        Object.freeze(this);
        Object.freeze(this.prototype);
    });
    class k extends klass {
        #id = null;
        constructor(...args) {
            if (new.target !== k) {
                throw new TypeError(`class can not be extended`);
            }
            super(...args);
            Object.seal(this);
        }
        static {
            function validate(v) {
                if (!(#id in v)) {
                    throw new TypeError("method can not be used on different class");
                }
            }
            const descriptors = Object.getOwnPropertyDescriptors(klass.prototype);
            for (const prop of Reflect.ownKeys(descriptors)) {
                const desc = descriptors[prop];
                if (desc.get) {
                    const original = desc.get;
                    desc.get = function () {
                        validate(this);
                        return original.call(this);
                    };
                }
                if (desc.set) {
                    const original = desc.set;
                    desc.set = function (v) {
                        validate(this);
                        original.call(this, v);
                    };
                }
               if ("value" in desc) {
                    const original = desc.value;
                    if (typeof original === "function" && original !== Klass) {
                        function n(...args) {
                            validate(this);
                            return original.apply(this, args);
                        }
                        Object.defineProperty(n, "length", { value: original.length });
                        Object.defineProperty(n, "name", { value: original.name });
                        desc.value = n;
                    }
                }
                Object.defineProperty(klass.prototype, prop, desc);
            }
            Object.freeze(klass.prototype);
        }
    }
    Object.defineProperty(k, "length", { value: klass.length });
    Object.defineProperty(k, "name", { value: klass.name });
    return k;
}

Usage:

@final
class X {
    foo() {
        console.log('hello');
    }
}

new X().foo(); // all OK, logs "hello"

// trying to use prototype methods on something else:
function Z() { }
Z.prototype = X.prototype;
let z = new Z();
z.foo(); // TypeError 'method can not be used on different class'

// trying and extend the class:
class Y extends X {}
new Y() // TypeError 'class can not be extended'
2 Likes

Why did you freeze klass.prototype but not k.prototype?

(I'd probably do (k.prototype = klass.prototype).constructor = k; anyway to get rid of the extra prototype chain link)

1 Like

Ah yes, no reason I just forgot. Good catch. I'll edit it to include that.

class prototypes can't be replaced like function style constructors can.

I didn't think about decorators, but thanks @aclaymore - I'll probably shamelessly steal your code snippet there once decorators reach stage 4.

Native syntax for this sort of thing would always be nice, but I'm good letting the idea rest until decorators come out, and people have a chance to make utility libraries that have decorators like this, and we can see how often such libraries get used.

2 Likes

For the basic case, if (new.target !== TheClass) throw new TypeError('TheClass is final'); works fine, and is not particularly verbose.

Yes, this won't stop people from doing shenanigans like adding the class prototype to another object's prototype chain explicitly, but I don't see much reason to bother doing so when the point is merely to indicate to consumers that they're not supposed to extend the class.

1 Like

That, combined with a private field (#x;) and a this.#x; at the top of every prototype method, would also ensure none of the prototype methods can be used except on instances of the "final" class.

1 Like