はじめに
TSのコンストラクタで Object.assign
を使いたくなることって結構ある気がするんですが、危険なこともあるよね。
TSでコンストラクタの引数の型を指定していれば大丈夫じゃんという気が一瞬するんですが、 TSの型は構造的部分型なので、場合によっては意図していないプロパティに値が代入されます。
type HogeInterface = {
id: number;
name: string;
foo: string;
bar: string;
};
class Hoge implements HogeInterface {
constructor(params: HogeInterface) {
Object.assign(this, params);
}
}
こういう感じの。自分でやりたくなって、でも感覚的に怖いなーと思って。ちょっと考えたらやっぱりダメな時もありますね。
意外と日本語でこれに言及している情報がパッと見当たらなかったので書いておきます。
意図しないプロパティに代入される例
例えば、クラスに何らかのプライベートにしたいプロパティがあるとします。
コンストラクタでもそのプロパティはいじらないという場合を想定します。
type HogeInterface = {
id: number;
name: string;
foo: string;
bar: string;
};
class Hoge implements HogeInterface {
id = 0;
name = "";
foo = "";
bar = "";
// 触ったらクビになる。ツラい。
private __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = 9999;
constructor(params: HogeInterface) {
Object.assign(this, params);
}
// 実際には存在しないはずだけど確認用にゲッターを用意
get SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED() {
return this.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
}
}
大丈夫な例
const hoge1 = new Hoge({
id: 123,
name: "Hoge 1",
foo: "foooooo",
bar: "barrrrr",
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: 0,
});
// error TS2345: Argument of type '{ id: number; name: string; foo: string; bar: string; __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: number; }' is not assignable to parameter of type 'Partial<HogeInterface>'.
// Object literal may only specify known properties, and '__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED' does not exist in type 'Partial<HogeInterface>'.
// お、エラー出してくれてるね↑
コンストラクタの引数である params
には型がついているので一見大丈夫そうに見えますが...
ダメな例
// おや、なんか怪しいオブジェクトが。
const maliciousHogeParams = {
id: 123,
name: "Hoge 1",
foo: "foooooo",
bar: "barrrrr",
// ダメな値が入っている
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: 0,
};
// あれ...?エラー出ないぞ?
const hoge2 = new Hoge(maliciousHogeParams);
console.log(hoge2.SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED);
// 出力: 0
// 変更されてるううわあああああああ
解雇されてしまいました。
なぜなのか
TSの型は構造的部分型と言って、つまりはダックタイピングなのですが、「必要な値が揃っていればその型である(その型に準拠している)とみなす」という仕組みです。
なので、上の例で言えば必要なプロパティであるid
, name
, foo
, bar
が揃っていれば、コンストラクタの引数に渡せることになります。
HogeInterface
という型であるかどうかに、その他のプロパティは関係ないのです。
じゃあ逆になんで大丈夫な例は大丈夫なのか(なぜエラーを出してくれるのか)と言うと、TSの親切な機能として Excess Property Checking という機能があり、リテラルで渡す場合は特別にエラーを出してくれるのです。
ここに書いているので気になれば読んでみてください。
上記の例でObject.assign
の型定義は以下の通りです。(v5.1.6)
ObjectConstructor.assign<this, HogeInterface>(target: this, source: HogeInterface): this & HogeInterface
HogeInterface
は推論で出してくれていますが、上記の通り構造的部分型なので別のプロパティが入っていてもエラーにはなりません。
Object.assign
はsource
が持つプロパティを全てtarget
にコピーするので、ダメな値も型をすり抜けてコピーされるということですね。
じゃあどうするのか
無視してObject.assign
を使う
必ずだめというわけではなく、クラスのプロパティに上記の例のようないじってはいけない値が存在しなければ、使っていても問題は起きません。
ただまあ、個人的にはその判断を都度行うのかとか、別の人がそういうプロパティを後から追加したらどうなのかとか、色々と気になるのでコンストラクタでは使わない方が良いんじゃないかなーとは思います。
プレーンなオブジェクトを使うのではなくわざわざクラスを作る場合って内部的なプロパティを持つ場面が多いしなあ。
普通に書く
コンストラクタ内でプロパティごとに代入式を書けば当然こんなことは起きません。
クラスをやめる
まあ、クラスじゃなくてもObject.assign
を使うと同じ問題は起きるんだけどね。
コンストラクタで使いたくなりがち。
おしまい
ということで、どうするかはケースバイケースですが、この危険性を知らずにいると怖いと思うので共有でした。