TypeScriptでes2022前後でのClass fieldの挙動の変化

さくっとnodeでAPIサーバを作るミッションがあり、簡単なKVS用のORMライクなものが欲しくなったので3年ぐらい前に書いたコードから引っ張ってきたのですが動きません。引っ張られ元は現在でも絶賛稼働中のプロダクトなので、戸惑いました。

調べると、新しいプロジェクトはtargetがes2022、古い方はes2019でした。

class Model {
  static get<T extends Model>(this: { new (data: any): T }, raw: string): T {
    return new this(JSON.parse(raw))
  }

  constructor(data: any) {
    Object.assign(this, data)
  }
}

class User extends Model {
  id: string ///< strictだと初期化してねぇぞのエラーがTSから出る
  name: string
}


const user1 = new User({ id: '1', name: 'foo' })
console.log(user1.id, user1.name)
const user2 = User.get(`{"id":"2","name":"bar"}`)
console.log(user2.id, user2.name)
{
  "compilerOptions": {
    "lib": ["dom", "es2023"],
    "module": "node16",
    "moduleResolution": "NodeNext",
    "target": "es2022",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    "baseUrl": ".",
    "outDir": "dist",

    "paths": {
      "@src/*": ["./src/*"],
      "@test/*": ["./test/*"]
    }
  }
}

今回の話のポイントだけ抜き出すと、こんな感じのコードになります。環境はTypeScript v5.2.2, ts-node v10.9.1 です。動かすと以下のような結果になります。

$ yarn ts-node -O '{"target":"es2022"}' test.ts
undefined undefined
undefined undefined
$ yarn ts-node -O '{"target":"es2021"}' test.ts
1 foo
2 bar

望んでいる挙動はtarget: es2021の方ですが、なぜこうなってしまうのでしょうか? ちょっと変えてみます。

class Model {
  static get<T extends Model>(this: { new (data: any): T }, raw: string): T {
    return new this(JSON.parse(raw))
  }

  constructor(data: any) {
    Object.assign(this, data)
    console.log('Model constructor', this)
  }
}

class User extends Model {
  id: string ///< strictだと初期化してねぇぞのエラーがTSから出る
  name: string = '__default__'

  constructor(data: any) {
    super(data)
    this.id = data.id
    console.log('User constructor', this)
  }
}
$ yarn ts-node -O '{"target":"es2022", "strict":true}' test.ts
Model constructor User { id: '1', name: 'foo' }
User constructor User { id: '1', name: '__default__' }
1 __default__
Model constructor User { id: '2', name: 'bar' }
User constructor User { id: '2', name: '__default__' }
2 __default__

(Userにもconstructorを設定したのでstrict: trueにできる)
Modelのコンストラクタが動いた後、Userのclass field初期化が動いて、Userのコンストラクタが動いているようです。まぁそうか。しかしどうしたらいいんだ。僕はお手軽なORMがほしいんだ。
手詰まり感があったので識者に聞いてみました

es2022からclass fieldの挙動が変わっており、「メンバの型付け」だけをしたい場合は以前のような書き方はできないようです。

 

初期化の順番

TypeScript: Documentation – Classes : Initialization Orderによれば、初期化の順番は以下の通りとのことで、推測通りでした。

The base class fields are initialized

The base class constructor runs

The derived class fields are initialized

The derived class constructor runs

 

派生クラスに型だけ付けたい場合は?

TypeScript 3.7の解説によれば、この挙動はuseDefineForClassFieldsフラグによるものだそうです。(targetをes2022以上にしているとtrueになる)

基本的にはECMAScriptの方針に沿うべきなのでこのフラグはそのままで、以前のようにメンバの型付けをしたい場合は declare 前置詞をつけろ、ということでした。

class User extends Model {
  declare id: string
  declare name: string
}
$ yarn ts-node -O '{"target":"es2022", "strict":true}' test.ts
1 foo
2 bar

望み通りの結果になりました。

というわけで、僕のやりたい用途では declare 前置詞を使えば問題がなさそうです。

 

何なのこれは

(3年前にはTypeScript 3.7出てるやんけ)というのは棚に上げて、なんで挙動を変えたのでしょうか。困るよキミィ。サイトに書いてありました。

TypeScript introduced class fields many years before it was ratified in TC39. The latest version of the upcoming specification has a different runtime behavior to TypeScript’s implementation but the same syntax.

https://www.typescriptlang.org/tsconfig#useDefineForClassFields

TypeScriptが先にこの構文を使っていたら、後から別の仕様でECMAScriptが実装されちゃったんですね仕方ないですね。

 

教訓

なんとなくでコードをかかない。