Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

TypeScriptとJavaScriptのclass継承の違い

概要

こんにちは。サーバーサイドエンジニアの窪田です。

ヤプリでは

  • Webフロントエンド (Nuxt.js, Next.js)
  • 自動化しているアプリのbuild処理 (Node.js)
  • 一部のPush通知の送信基盤 (Node.js)

でJavaScriptとTypeScriptを使っていたり、試験的にDenoを使ったプロダクトもあったりして、 JS, TSへの理解が必要になってきます。

最近、TypeScriptのclassの継承周りで一部勘違いしていたことがあったので、 その事例を交えて整理してみます。

JavaScriptにおける継承の挙動

$~ node --version
$~ v18.2.0

の環境で試します。

// tryExtends.js
class BaseError extends Error {}
class CustomError extends BaseError {}

const hogeError = new CustomError();

console.log(hogeError instanceof CustomError);
console.log(hogeError instanceof BaseError);
console.log(hogeError instanceof Error);
$~ node tryExtends.js

この処理を実行した時、3個のconsole.logは全てtrueになります。

TypeScriptにおける継承の挙動

$~ npx tsc --version
$~ Version 5.1.3
$~ node --version
$~ v18.2.0

の環境で試します。

今度はTypeScriptでコンパイルしたのち実行してみます。

// tryExtends.js
class BaseError extends Error {}
class CustomError extends BaseError {}

const hogeError = new CustomError();

console.log(hogeError instanceof CustomError);
console.log(hogeError instanceof BaseError);
console.log(hogeError instanceof Error);
$~ npx tsc tryExtends.ts && node tryExtends.js

この結果はどうなるでしょうか?

答えは、「tsconfigのtargetによる」です。

$~ npx tsc --lib dom, es2022 --target es2022 tryExtends.ts && node tryExtends.js

このようにtargetをES6以上に設定した場合の出力は

console.log(hogeError instanceof CustomError); // -> true
console.log(hogeError instanceof BaseError); // -> true
console.log(hogeError instanceof Error); // -> true

になります。 一方で

$~ npx tsc --lib dom, es2022 --target es5 tryExtends.ts && node tryExtends.js

のようにtargetをES5以下を指定した場合の出力は

console.log(hogeError instanceof CustomError); // -> false
console.log(hogeError instanceof BaseError); // -> false
console.log(hogeError instanceof Error); // -> true

になります。

なぜこの違いがあるのか

結局はESのversionの違いが本質的な違いです。 TypeScriptかどうかはあまり関係ありません。 ES6でclass構文が登場したため、targetがES6以降なら対応がありますが、ES5以前は自前で継承を実装する必要があります。

そもそも、JavaScriptにおいて、 class A がclass Bを継承しているということは、class Aのインスタンスから辿れるプロトタイプチェーン上にB.prototyeが登場するということです。

以下をES6をターゲットにコンパイルし、実行すると、

class BaseError extends Error {}
class CustomError extends BaseError {}

const hogeError = new CustomError();

console.log(hogeError instanceof CustomError); // -> true
console.log(hogeError instanceof BaseError); // -> true
console.log(hogeError instanceof Error); // -> true

console.log(custom.__proto__ === CustomError.prototype); // -> true
console.log(custom.__proto__.__proto__ === BaseError.prototype); // -> true
console.log(custom.__proto__.__proto__.__proto__ === Error.prototype); // -> true

コメントで示した出力になります。 customオブジェクトのプロトタイプチェーンを辿ると、CustomError, BaseError, Errorオブジェクトのprototypeをそれぞれ参照していることを確認できます。

一方で、ES5をターゲットとしたときはCustomError, BaseErrorのprototypeがcustomオブジェクトのプロトタイプチェーンに現れません。 そのため、ES5をターゲットにする場合は明示的にprototype登録する必要があります。

class BaseError extends Error {
  constructor() {
    super();
    Object.setPrototypeOf(this, new.target.prototype); // <- prototype登録
  }
}
class CustomError extends BaseError {}

const hogeError = new CustomError();

console.log(hogeError instanceof CustomError); // -> true
console.log(hogeError instanceof BaseError); // -> true
console.log(hogeError instanceof Error); // -> true

console.log(custom.__proto__ === CustomError.prototype); // -> true
console.log(custom.__proto__.__proto__ === BaseError.prototype); // -> true
console.log(custom.__proto__.__proto__.__proto__ === Error.prototype); // -> true

なぜこれを考えたかの経緯

実は、現状でプロダクトコードにObject.setPrototypeOf()が乱用されていた部分がありました。 ES5の時代をそんなに生きていない自分にはよくわかっておらず、なぜその記述が必要だったのかを理解できていませんでした。 しかも、tscコンパイルのtargetは既にES2018に上げられていたため、prototype登録は必要なくなっている状態で、1行を消しても動作する状態にあり余計に混乱したという経緯がありました。

教訓

  • JavaScriptとTypeScriptでclass構文は違う(当たり前。。。)
  • classだからと言ってコンパイル後のJSがES6以上と思い込んではいけない
  • もし、ES5以下 -> ES6以上のtargetのアップデートをする場合は、ES5でしか動作しないコードは消す

これらのことは意識していきたいです。

まとめ

今回、ES5が関わる少しニッチな内容でしたが、今一度class構文とは何なのかを考えることができてよかったです。

冒頭で述べた通り、ヤプリではWebフロントを中心にJavaScript, TypeScriptのプロダクトがあります。 興味がある方はぜひカジュアル面談にお越しください。

open.talentio.com