Sealed class in Javascript

Proposal : Sealed class in JavaScript.

In some programming languages, a "sealed class" is a class that cannot be subclassed or extended. This means that no other class can inherit from a sealed class. This concept is often used to restrict the hierarchy of classes for various reasons, such as ensuring a specific design or preventing unintended modifications.

In Java a class marked final cannot be inherited. In Kotlin sealed keyword is used to declare a class final.

Java:

final class SealedClass{}

Kotlin:

sealed class MySealedClass {}

JavaScript itself doesn't have a concept of "sealed classes" like some other languages do, where you explicitly mark a class as sealed, preventing it from being extended.

There are ways to make an object freeze but that does not perfectly serve the use case.
While few approaches provide a level of encapsulation and protection, JavaScript is a flexible language, and developers can always find ways to modify or extend objects.

proposal for sealed class will allow a class not be to inherited, but abstract classes cannot be made sealed because if a abstract class is sealed then we are explicitly saying we are not allowing inheritance then what is the point of abstract class where we want child classes to override the methods

sealed class Single{ 
    constructor(){}
}
class Double extends Single{} // TypeError : Sealed classes cannot be inherited
const o=new Single(); // works fine

if a class want to extend the sealed class we can specify the permit to Which class we want to allow but that class will be sealed too which is permitted.


sealed class OtherClass{} // this must be sealed class if it is permitted
sealed class Myclass permits OtherClass{} 
// we are allowing OtherClass to extend MyClass

Refrences :

One way to get a similar effect is to check new.target:

class C {
  constructor() {
    if (new.target !== C) throw new TypeError("C is final");
  }
}

class D extends C {}

new D(); // TypeError

This pattern could be also be abstracted and expressed using a decorator:

@final
class C {}
3 Likes

It's impossible in JS to prevent someone from extendsing you, because there's no hook for it, and a child class could bypass the new.target trick by returning an object, so there's really no way to make a truly "final" class.

But if a child class returns an object, then the parent class's constructor would never run, correct? (meaning the instance was never properly constructed). Nor would you really have access to an instance that was linked to the parent class in any way, instead you would just get the object literal that was returned.

No, the constructor only knows that itself returns an object when its execution finishes, but the parent constructor is run before the child constructor runs, so the parent constructor has already run, just that it could be useless.

Not if you use this to construct the return value. For example, return new Proxy(this, { ... }).

If you use this, you have to call super, so yes, you are correct; if your "final" class used an instance private field and brand checked the receiver in every public method, then the primary hole left would probably be instanceof and Object.prototype.isPrototypeOf.

1 Like

Instanceof is easy to deal with, with Symbol.hasInstance :)

Right, but i mean that the fake subclass would still be able to pretend that it was instanceof SealedClass.

Not sure how - if the super class defines a Symbol.hasInstance method that is implemented by, say, checking for the existence of that private field, then how would "subClassInstance instanceof Sealed class" ever return true?

E.g.

class SealedClass {
  #isInit = false;
  constructor() {
    if (new.target !== SealedClass) {
      throw new TypeError("SealedClass is sealed");
    }
    this.#isInit = true;
  }

  static [Symbol.hasInstance](value) {
    return #isInit in value && value.#isInit;
  }
}

const obj1 = new SealedClass();
console.log(obj1 instanceof SealedClass); // true

class FakeDerivedClass {
  constructor() {
    return Object.create(SealedClass.prototype);
  }
}

const obj2 = new FakeDerivedClass();
console.log(obj2 instanceof SealedClass); // false

ah, you’re right, fair.

You should be able to pull that off with something like

function unseal(Sealed) {
  return class Unsealed extends Sealed {
    constructor (...args) {
      return Object.setPrototypeOf(new Sealed(...args), new.target.prototype)
    }
  }
}

Complete example below. Also included the sealed hasInstance check in the fake class just to show the privates in that class get installed on the new fake instance as well.

class SealedClass {
  #isInit = false;
  constructor() {
    if (new.target !== SealedClass) {
      throw new TypeError("SealedClass is sealed");
    }
    this.#isInit = true;
  }

  static [Symbol.hasInstance](value) {
    return #isInit in value && value.#isInit;
  }
}

const obj1 = new SealedClass();
console.log(obj1 instanceof SealedClass); // true

function unseal(Sealed) {
  return class Unsealed extends Sealed {
    constructor (...args) {
      return Object.setPrototypeOf(new Sealed(...args), new.target.prototype)
    }
  }
}

class FakeDerivedClass extends unseal(SealedClass) {
  #fakeInit = false;
  constructor() {
    super()
    this.#fakeInit = true;
  }
  
  static [Symbol.hasInstance](value) {
    return #fakeInit in value && value.#fakeInit;
  }
}

const obj2 = new FakeDerivedClass();
console.log(obj2 instanceof FakeDerivedClass); // true
console.log(obj2 instanceof SealedClass); // true
console.log(FakeDerivedClass.prototype.isPrototypeOf(obj2)); // true
console.log(SealedClass.prototype.isPrototypeOf(obj2)); // true

Ah, shucks. Perhaps we can patch that with a modified hasInstance check.

class SealedClass {
  ...
  static [Symbol.hasInstance](value) {
    return #isInit in value && value.#isInit && Object.getPrototypeOf(value) === SealedClass.prototype;
  }
}

Some other good modifications that could be done:

  • Using Object.preventExtensions() in the SealedClass's constructor to prevent you from tampering with the prototype of created instances (though it wouldn't prevent the use of Object.create() on new instances). This would also prevent inheritance-like behavior of creating an instance, then adding your own custom methods to the instance.
  • I'll go ahead and throw in the example of "brand checking" in every method that @ljharb mentioned - and I'll do it by using the instanceof check just coded up.
class SealedClass {
  #isInit = false;
  constructor() {
    if (new.target !== SealedClass) {
      throw new TypeError("SealedClass is sealed");
    }
    this.#isInit = true;
    Object.preventExtensions(this);
  }

  static [Symbol.hasInstance](value) {
    return #isInit in value && value.#isInit && Object.getPrototypeOf(value) === SealedClass.prototype;
  }

  addNumbs(x, y) {
    if (!(this instanceof SealedClass)) {
      throw new TypeError("'this' must be a non-inherited instance of SealedClass");
    }
    return x + y;
  }
}

You'd also probably want to freeze the class and maybe its prototype too after setting it all up.

1 Like

Yeah if the sealed class makes its instances non-extensible, no properties or prototype can be re-configured.

If you prevent extensions and brand check in every method, the check for prototype in hasInstance is superfluous.

However you don't want to use instanceof to do the brand checks because that can be overidden by the object.

1 Like