Differences in output of Typescript compiler and Babel for classes
Recently, I worked on switching our entire frontend codebase from using ts-loader to use babel-loader. In doing so, I ran into some discrepancies in the compiled output of classes using the Typescript compiler vs. Babel.
Background
Originally (before my time) at Course Hero, we were building our react apps using both ts-loader and babel-loader. ts-loader would compile Typescript to ES6, then babel-loader would transpile ES6 to ES5. However, since the Typescript compiler can target ES5 directly, we chose to remove Babel from the build process so that we were just using ts-loader. This was simpler and also improved the speed of our builds.
Not long after this, though, @babel/preset-typescript was released, and it became very easy to compile Typescript to Javascript with Babel. Evens so, we continued to use ts-loader for some time because it was working well for us and we didn’t have a compelling reason to use Babel instead. That changed when I saw react-refresh. Our existing local development experience with react-hot-loader was often frustrating and unreliable. I realized we could drastically improve the local development experience if we could use react-refresh, but this would require us to switch to Babel. So I got to work.
Switching from ts-loader to babel-loader is pretty straightforward for the most part — swap out ts-loader with babel-loader in the webpack config. However, there are a couple things to be aware of. Microsoft’s blog post about using Typescript and Babel 7 points out that namespaces and const enums don’t work with Babel. I also saw this mentioned in several other articles. If you’re worried about this, I wouldn’t be. A program manager on the Typescript team explained why it’s no big deal here.
However, there were two other surprising differences that I ran into. These were more difficult to debug because, unlike the examples above, the code successfully compiles with both the Typescript compiler and with Babel, but the output is observably different between the two.
1. Enumerability of class methods
When Typescript compiles classes, it marks class methods enumerable. This is not in line with the spec for ES6 classes, which says that class methods should be non-enumerable. When compiling typescript code with Babel, class methods are marked non-enumerable. Some reasoning for why the Typescript compiler marks the methods as enumerable is provided in this GitHub issue.
Ways to preserve existing behavior
- Use class properties instead of class methods
This is a good option if it’s easier for you to change all the necessary classes than to change all the usages that would be affected by enumerability of methods.
Before:
class MyObject {
myMethod() {
...
}
}
After:
class MyObject {
myMethod = () => {
...
}
}
- getOwnPropertyNames
This is a good option if it’s easier for you to change the places that would be affected by enumerability of methods than to change the classes that have those methods.
Before:
for (const property in obj) {
...
}
After:
for (const property in Object.getOwnPropertyNames(obj)) {
...
}
2. Uninitialized class properties
When Typescript compiles classes, it doesn’t generate any code for uninitialized class properties.
Input:
class ImplicitlyUndefinedProperties {
public foo: string;
public bar: string;
}
Output:
var ImplicitlyUndefinedProperties = (function () {
function ImplicitlyUndefinedProperties() {
}
return ImplicitlyUndefinedProperties;
}());
This is inconsistent with the spec for ES6 classes, which says that these properties should be initialized as undefined. Babel does this correctly. You can imagine that the code Typescript generates is like
class MyObject extends BaseObject {
constructor(data) {
super(data)
}
}
and the code generated by Babel is like
class MyObject extends BaseObject {
constructor(data) {
super(data)
this.property1 = undefined
this.property2 = undefined
}
}
This was an issue for us because we had some classes that extended a base class whose constructor looked like
constructor(data: any = {}) {
Object.assign(this, data);
}
The subclasses were then used as
const obj = new MyObject({a: 1, b: 2})
// obj.a === 1 is expected to be true
The code relied on the base class to copy any properties passed into the constructor onto the instance of the class.
Related GitHub issues:
- https://github.com/microsoft/TypeScript/issues/12437
- https://github.com/microsoft/TypeScript/issues/28823
Ways to preserve existing behavior
- Add a constructor to all subclasses
The benefit of this approach is that you do not need to change the way these classes are used and (at least in our case) it was easy to accomplish by a global find and replace.
- Initialize properties to themselves
Similar to the solution above, this approach does not require you to change the way these classes are used.
class MyObject extends BaseObject {
id: number = this.id;
name: string = this.name;
}
- Static create method in base class instead of constructor
The benefit of this approach is that you will not need to change the subclasses. The downside is that you will need to change each place that instantiates a subclass.
This is a good option if it’s easy to find all places where the constructor of the class is called because it will need to be replaced with a call to this static method.
class BaseObject {
static create(data) {
const instance = new this();
Object.assign(instance, data);
return instance;
}
}
class MyObject extends BaseObject {
...
}
// and used as...
MyObject.create({ id: 1, name: 'test' })