さくっと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が実装されちゃったんですね仕方ないですね。
教訓
なんとなくでコードをかかない。