Classes and Metaclasses in TypeScript

Greg Pabian
4 min readOct 18, 2020

--

Introduction to TypeScript classes

I believe most JavaScript and TypeScript developers are quite familiar with the syntax required for defining and using classes:

class RegularClass {}

Even though ourRegularClass might look boring, there is more to it than meets the eye. The identifier RegularClass is used in two distinct contexts:

  • as the class identifier (during the class definition and usages of static properties),
  • as the type of the instance of the class (the instance is understood as an object that is returned by the constructor).

Realising this dichotomy allows us to see that the meaning of the aforementioned identifier is based on the context in which it is used. It only gets more complex if we tried to define an interface called RegularClass as the TypeScript compiler would not complain at all and we would be able to define properties on the interface level that are not necessarily reflected on the class level.

An avid reader might point out that we still have not figured out the type of the class. In other words, what is the return type of a function that returns a class? We clearly see that it cannot be the same as the class identifier. As it turns out, the answer is really straightforward but the its implications are quite jarring.

Using typeof with classes

In order to get the type of the class in question, we can simply write:

type RegularClassType = typeof RegularClass;

The RegularClassType type contains (among others):

  • the prototype (type) under RegularClassType['prototype'] which is equivalent to RegularClass (we cannot use this type though to instantiate new instances or define inherited classes),
  • all static variable types (continuing from our original example: classVariable).

In type theory, the type of a class is usually called metaclass. Some academic definitions require metaclasses to be actual classes (so metaclasses instances are indeed classes) but this way of thinking becomes debatable in the JavaScript context as the language’s object model is not inheritance-based but prototype-based and classes are nothing more than syntax sugar supported natively only from recent years onwards.

Using InstanceType with metaclass

Should we desire to get the instance type under a metaclass, we could write:

type RegularClassInstance = InstanceType<RegularClassType>;

The native InstanceType<T> helper works in a way that if the underlying type T can be initialised used the new operator (in other words, it has a constructor), it yields the return type of that constructor (which happens to be the instance type).

One may ask what the reason for such complexity is. Again, this comes directly from intrinsic workings of JavaScript (in this particular case, how the new operator works, linking the prototype and injecting the context of the execution).

Since we now know how to extract the instance type from any class type, we can move on to examples.

Final classes in TypeScript

There is one engaging concept in Java (and other object-oriented languages as well) which is the ability to create final classes — classes that cannot be extended (inherited from). Whether it is a good idea from a architecture design perspective or not (in my opinion, only in very rare cases), we can inspect such a possibility based on our scientific curiosity.

In case we wanted to create a final class, we could hide the class in question behind a builder:

const buildFinalClass = () => {
class FinalClass {}

return FinalClass;
}

type FinalClass = ReturnType<typeof buildFinalClass>;

class ExtendedClass extends FinalClass {} // does not compile

There’s one fundamental problem with this builder as we cannot easily build a new instance of FinalClass. Fortunately, there exists an easy solution to that problem:

const buildFinalClass = () => {
class FinalClass {}

return {
FinalClass,
buildInstance: () => new FinalClass(),
};
}

const instance = buildFinalClass().buildInstance();

As you can see in the example above, the buildFinalClass function returns now another function for building instances and FinalClass itself. In spite of the fact that I return the class, I am not able to create a subclass using the extends keyword. Showing this failure was the very point of the aforementioned snippet and it should be understandable at this point that there is no point of returning the FinalClass from the builder, so we can see the final (pun intended!) snippet:

const buildFinalInstance = () => {
class FinalClass {}

return new FinalClass();
}

const instance = buildFinalInstance();

Hiding classes with mutable static properties

It might happen that we are forced to use a class with mutable static properties — for instance, if it comes from an external library. One of the problems that are likely to arise is having those properties changed during the runtime in an unpredictable way. In order to mitigate that we can do the following things:

  • inherit from the aforementioned class (if possible) or access this class only in that one place,
  • wrap the class in the class builder, passing all necessary dependencies,
  • return the class from the builder (or functions that modify the static properties explicitly),
  • use the builder to create one instance (or multiple ones) of this class internally, all of them controlled by a closure defined in the builder — it ensures that the hidden class itself is not leaked to the global scope.

In order to achieve that in a TypeScript project, we can use all the knowledge gathered in this article. The approach discussed here has obvious connotation to the singleton design pattern but in our case, we are hiding a singleton behind a provider function (which is the very definition of the bridge design pattern).

Summary

TypeScript supports metaclasses in a way that fits the conceptual standpoint of what JavaScript specification and engines offer. On one hand, the obvious need to adhere to certain requirements reduces the final complexity of the delivered feature, but on the other hand, TypeScript metaclasses offer less functionality than e.g. Python metaclasses.

Personally I don’t think it is ever necessary to use metaclasses (as there are always other possible approaches to solving problems) but TypeScript allows us to use metaclasses to enforce certain design decisions on software developers or shield them against decisions that were made in the past.

--

--

Greg Pabian
Greg Pabian

Written by Greg Pabian

A Full-stack Software Engineer that loves building products. Disclaimer: https://gist.github.com/grzpab/3cf57878ffce7d5271298ccc473bcb98

No responses yet