1

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?

phb
  • 691
  • 4
  • 7
  • Presumably you're targeting runtimes that don't support [class fields](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields), so you need some kind of downleveling. If you want something more like "plain old JavaScript" then you can target a newer runtime supporting it as shown [in this playground link](https://tsplay.dev/wXlrOw). Does that fully address the question? If so I'll write up the answer explaining; if not, what am I missing? – jcalz Jul 06 '23 at 12:47
  • Sorry for my incorrect comment. Yes indeed, it does seem to solve the problem. That said, I suspect I should file a bug about this to the typescript team -- it seems that we now get different behaviour depending on the target language level. Ie, if targeting javascript pre-classes, any closure used as a member field initialiser will incorrectly retain a reference to all constructor arguments. It probably should emit a helper function called by the constructor instead to avoid this issue. – phb Jul 06 '23 at 20:28
  • I doubt they’ll want to address that: downleveling doesn’t have to be “perfect”, it just has to allow older runtimes to use the newer features in most reasonable scenarios. But I’m not an authority on that. – jcalz Jul 06 '23 at 22:02
  • Just wanted to add one more comment here to close this out. It turns out I was targeting an older version of the JS runtime then I was expecting due to using bazel/concat-js to build my typescript, and it overwrote the target setting in my tsconfig.json – phb Jul 19 '23 at 01:05

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360