typescriptclassmemoryscopes

Typescript class member initialisers are inlined into the constructor of the class


This behaviour seems fairly well documented (See Initializing variables inline during declaration vs in the constructor in Angular with TS here on SO for example), but it can cause some really hard to track down memory issues.

See the following example

class Bar {
    private num: number;
    private closure: any;
  constructor(num: number, closure : any) {
    this.num = num;
    this.closure = closure;
}
}

class Foo {
  private bar = new Bar(5, (a: number, b: number) => {return a < b;});
  private baz: number;
  constructor(very_expensive_thing: any) {
    this.baz = very_expensive_thing.small_thing;
  }
}

If this would have been plain old javascript, there would be no issue as the Bar initialiser has no access to the very_expensive_thing object.

However, typescript inlines the initialiser of Bar into the constructor and as such it now retains the very_expensive_thing, see generated javascript from the typescript playground v5.1.3:

"use strict";
class Bar {
    constructor(num, closure) {
        this.num = num;
        this.closure = closure;
    }
}
class Foo {
    constructor(very_expensive_thing) {
        this.bar = new Bar(5, (a, b) => { return a < b; });
        this.baz = very_expensive_thing.small_thing;
    }
}

The retaining of a constructor parameter inside the closure is not intuitive and does not follow normal scoping rules.

While the fix is trivial (move the closure out of the class), it would be great to hear from more experienced typescript users whether this is a known pitfall or should be treated as an issue. Is there anyway to turn off the inlining behaviour to prevent this?


Solution

  • Class fields were officially introduced to JavaScript in ES2022. If you configure TypeScript to --target a version of the JS runtime before ES2022, then the code will be downleveled to work in such a version, by inlining the fields into the constructor. Note that TypeScript doesn't necessarily commit to downlevel every language feature in such a way that every nuance of the feature is preserved. Sometimes the benefit isn't seen to be worth the effort or added complexity; see issues like microsoft/TypeScript#32743, among others.

    If you don't want to see this happen and want the semantics to stick as close to JavaScript class fields as possible, you should target ES2022 or later (and make sure to enable the --useDefineForClassFields compiler option since class fields have slightly different semantics in JavaScript than the TypeScript team had thought when they were first implemented.) If you do that, you will get your output JavaScript to look almost exactly like the TypeScript code with no annotations or type modifiers:

    class Foo {
        bar = new Bar(5, (a, b) => { return a < b; });
        baz;
        constructor(very_expensive_thing) {
            this.baz = very_expensive_thing.small_thing;
        }
    }
    

    Playground link to code