Two weeks ago I wrote about class fields, TC39 and TypeScript. This time I want to go into the details of transpilation output related to class fields.

Intro

You see, I have this class in my TypeScript codebase:

class Foobar {
    public foo = 31;

    constructor(public x: string){
    }
    
    public bar = this.x.toUpperCase();
}

Obviously, this is some real production-grade code.

Now, to set the scene: my tsconfig.json has the target set to ES2021 and I am not using the useDefineForClassFields flag yet.

So the JavaScript result of running tsc looks like this:

class Foobar {
    constructor(x) {
        this.x = x;
        this.foo = 12;
        this.bar = this.x.toUpperCase();
    }
}

Idea 1: the flag

I want to start using useDefineForClassFields: true in my repo, to move my TypeScript source code closer to JavaScript.

So I turn this on in the tsconfig.json, and with the same ES2021 target, what I get from tsc now is this:

class Foobar {
    constructor(x) {
        Object.defineProperty(this, "x", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: x
        });
        Object.defineProperty(this, "foo", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: 12
        });
        Object.defineProperty(this, "bar", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: this.x.toUpperCase()
        });
    }
}

This is definitely more verbose, but not surprising, knowing why the flag was introduced in the first place.

So far, all’s fine. What’s interesting is that you might already see that there is a specific order in which the properties are defined. The first defined property is always the class field defined via parameter properties.

Idea 2(022): electric boogaloo

Since I’m already using the flag, and it is now year 2023 (so ES2022 is already released), why won’t I set the target to ES2022?

And that’s what I do, with the useDefineForClassFields flag turned on.

But tsc is not happy:

class Foobar {
    public foo = 31;

    constructor(public x: string){
    }

    public bar = this.x.toUpperCase();
    //                ^ Property 'x' is used before its initialization.(2729)
}

Weird.

I remove the bar property from the class, and this is the transpilation output:

class Foobar {
    x;
    foo = 31;
    constructor(x) {
        this.x = x;
    }
}

x;? No Object.defineProperty calls anymore? And why was suddenly bar illegal?

Looking for patterns

First thing that gets my attention is the fact that there are no Object.defineProperty calls anymore. I believe this is because ES2022 now has the class fields as part of the language, so the field being initialized like foo = 31; has to be realized as if there was the Object.defineProperty call in the code. That is why the useDefineForClassFields flag’s behaviour was to add these calls in the first place. Now with ES2022 being a thing, this is not necessary – the language imposes these [[Define]] semantics on the runtime.

Second thing is the lone x; expression. This does not look like it would be a requirement of the ECMAScript specification. I searched for some clues in the TypeScript repository, but I couldn’t find any proof for the hypothesis that it is by TypeScript’s design to define^Wassign1 the fields in this specific order: parameter properties, then class fields.

Because this is what it looks like:

Wild theories below

With TypeScript’s original decision to use the [[Set]] semantics for class field initialization, it was also decided that the parameter properies were always assigned first. An arbitrary decision, but it allows assignments like the one for baz, where the value is taken from a constructor argument. And also, you have to have some order, so placing the parameter properties first looks elegant as opposed to doing it in the order of appearance in the class (mixing class scope with constructor-parameter scope, yuck!). I wonder why weren’t parameter properties placed last, but it’s probably because placing them first gives you the baz assigment for free and placing them last gives you nothing.

Then, when it turned out that ECMAScript is going to go for [[Define]] semantics, TypeScript was forced to use Object.defineProperty. The order was kept, since the parameter properties are not a JavaScript feature2 and the designers wanted to keep the same behaviour. Notice that the bar initialization was legal with the ES2021 target. What’s more (that is not highlighted in my example) is that with the [[Define]] semantics TypeScript needs to emit statements for uninitialized fields, that were previously removed from the output (since their purpose was only type-checking).

Finally, when ES2022 arrived, the parameter properties needed to be “mentioned” at the top of the class definition, to keep the already established TS behaviour. But what’s the most interesting here is that with ES2022 target there is now a separation between definition and assignment – previously, it was happening in a single expression with the value sitting in the third argument of Object.defineProperty call.

What’s also interesting is that the output for a pre-ES2022 target with useDefineForClassFields does not separate the definition from assignment as the ES2022 output, while it is totally possible to do technically. My wild guess is that the difference was not made on purpose as a “migration stage”, but the final behaviour of ES2022 was decided after the flag was implemented in TS 3.7. But again, I have no proof to support this.

tsc, why

Consider this snippet:

class Asdf {
    public bar = this.foo;
    //                ^^^ Property 'foo' is used before its initialization.(2729)
    public foo = 31;
}

class Qwerty {
    public foo = 31;
    public bar = this.foo; // all's good
}

The order of field initializers is important to TypeScript. And because parameter properties are emitted first, that’s why it has been always legal to set bar like this:

class BarX {
    public bar = this.x;
    constructor(public x: string) {
    }
}

It looks like you’re trying to use x before it is initialized, but we already know that this is not the case for pre-ES2022 output.

But trying to transpile the BarX class above into ES2022, we will first get a type-checking error from tsc. TS Playground shows you what would the output be for this code:

class BarX {
    x;
    bar = this.x;
    constructor(x) {
        this.x = x;
    }
}

So here’s the problem. The parameter property was defined with ES2022 syntax at the top, but assigned in the constructor body, whereas the bar class field assigment was not moved to the constructor. It does make sense why TS reports an error (bar will be initialized with undefined), but it does not make sense to me why isn’t the output like the snippet below in the first place:

class BarX {
    x;
    bar;
    constructor(x) {
        this.x = x;
        this.bar = this.x;
    }
}

I assume this is due to “TypeScript not wanting to transform the source code” and “being as close to ECMAScript as possible”. Well, unfortunately you cannot have your cake and eat it too, because not only the sole existence of parameter properties forces you to transform the code in some way, it’s also the fact that useDefineForClassFields behaviour for pre-ES2022 was supposed to bring the forward compatibility with ES2022, but it turned out to have a different behaviour than ES2022. By chance it was the exact behaviour that we wanted, and now, we don’t have it.

swc, hi

And here’s the same snippet ran through swc with ES2022 and useDefineForClassFields set to true (by default):

class BarX {
    x;
    bar;
    constructor(x){
        this.x = x;
        this.bar = this.x;
    }
}

Yes, the default ES2022 behaviour is to emit the equivalent of tsc’s pre-ES2022 + useDefineForClassFields output.

Too bad we cannot just use it, since tsc is going to report compiler errors on the source code anyway.

What will I do, a.k.a “Closing words”

I’ll probably change my original Foobar class from this:

class Foobar {
    public foo = 31;

    constructor(public x: string){
    }
    
    public bar = this.x.toUpperCase();
}

to this:

class Foobar {
    public foo = 31;

    constructor(public x: string){
        bar = this.x.toUpperCase();
    }
    
    public bar: string;
}

because this is all it takes to get the old behaviour (for the bar field assignment) back in ES2022.

But I’ll be cautiously keeping an eye at all the syntax sugar that is provided by TS from now on.

Post scriptum

In C# a field initializer cannot refer to other instance fields. You can only do the assignment in the constructor body.

Had this rule been in TS from the start, we wouldn’t have ended up with some code that is now only valid for old compilation targets.


  1. the whole thing is actually about the assumption it was supposed to be an “assignment”, not a “definition” ↩︎

  2. yet ↩︎