Node.js
TypeScript

TypeScriptでのNodeのカスタムエラーの作り方

More than 1 year has passed since last update.

Twitterで下手なことを言うのはダメですね。「TypeScriptアドベントカレンダー無いなー。あったらまあ実務で使ってるし、一回ぐらい、TypeScriptはクソだって記事書くテロして去ろうと思ってたのになー」とかいう旨を物凄く圧縮してツイートしたら、突然更地が12/3にできました。

僕としては、できれば@falsandtruさんの記事が読みたかったんですけど、もしかしたら12月は忙しいのかもしれない。

そんな感じで、やっていきたいと思います。三日間よろしくお願いします。僕はプログラミング初心者なので、間違いあれば随時コメントで指摘をもらいたいです。よろしくお願いします。

さて、今日は多分フロントエンドエンジニアには、何も読んで嬉しく無いことを書きます。主にNodeユーザー向けの話です。


元ネタ

以下は、Nodeユーザーなら一度は行き当たったことのある投稿では無いでしょうか?

さて、Errorをカスタムするというのは、アプリを作る上で非常に重要なトピックです。しかしながら、このエラーの扱いにはどの言語も苦労しているんですね。大体どの言語でもまともな文献に行き当たりません。

僕は普段は日曜Haskellerをやっているんですが、Haskellでもエラーの扱いは複雑な変遷を辿っていて現在も決着がついていません。最近GHC8がスタックトレースの改善を行ったんですけど、こんな話この記事読んでる人には何の益も無い話ですね。とにかくエラーの扱いはとても難しいらしいです。

さて、我らがJavaScriptも例外ではありません。MDNさんのError.prototype.stackのページを見に行ってみると、早速でかでかと以下のことが書いてあります。


非標準

この機能は標準ではなく、標準化の予定もありません。公開されている Web サイトには使用しないでください。ユーザーによっては使用できないことがあります。実装ごとに大きな差があることもあり、将来は振る舞いが変わるかもしれません。


最高ですね。さて、我らがV8では、結構早い段階からstackTraceをユーザーがカスタマイズできる汎用的な方法が用意されています。特にNode.jsではErrorオブジェクトは、EventEmitterやAsync関数のコールバックなど様々なところで使われており、結構重要トピックです。Errorオブジェクトっぽく振る舞えるかどうかによって、多大なバグを引き起こしたりと、色々大変です。

しかしながらこれが、Errorをカスタマイズするという、ただそれだけのことに関してまともな文献が全く無いんですね。それどころか、ライブラリごとに実装が異なるなど、目を疑う状況です。それはなぜかというと、まずJavaScriptではプロトタイプベースであるために、オブジェクト同士をinheritするという、ただそれだけの基本的なことに関して標準的な方法が全く存在しなかったからです。しなかった、というのはES6でクラス構文とともにextendsが導入され、一応の決着がついたからです。一応といったのは、V8のError、つまり今回のトピックで片付かない問題があるからです。

まずは、この問題に行き当たったことの無い人もいるかもしれませんので、ひとまずどういうことかを見てみましょう。Node7には既にclass構文が導入されていますので、それを使ってみます。

$ node

> class A extends Error {}
[Function: A]
> console.log(new A('message'))
Error: message
at repl:1:13
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined
> console.log(new Error('message'))
Error: message
at repl:1:13
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined

はい、スタックトレースのメッセージが、ちゃんと表示されないんです。このスタックトレースメッセージを改善し、TypeScriptで汎用的にカスタムエラーを作成できるようにするのが今日の課題です1。では、張り切っていきましょー。


v8のStackTraceの流れ

まずは、StackTraceAPIを読みながら、v8のStackTraceの基本を確認しておきましょう。

v8のStackTraceAPIにおいてキーとなる関数は次の二つです。


  • Error.captureStackTrace(obj, func?)


    • objにスタックトレースを表示するstackプロパティを追加します

    • スタックトレースの内容は、この関数が呼び出された時の内容です

    • funcがある場合、そのfuncより上のスタックトレースはstackプロパティから排除してくれます

    • stackプロパティは作成された時は文字列になっておらず、一回目の呼び出しで文字列になるgetterになっています



  • Error.prepareStackTrace(obj, stackTraceData)


    • stackTraceのデータはこの関数を使用して書式化されます

    • objには投げられたエラーオブジェクト、stackTraceDataにはCallSiteオブジェクトの配列が渡されます

    • CallSiteオブジェクトは、スタックフレームの情報を取得するための幾つかのメソッドを持っています



Error.prepareStackTraceはスタックトレースをさらにカスタムできるので、何かデバッグの際もっと詳細なトレースが見たいといった時に便利ですね。

さて、今回最も重要なのが、Error.captureStackTrace関数ですね。この関数については使ってみれば、どういうものか分かりやすいので実際にどういうものか使ってみます。

> var a = {};

undefined
> Error.captureStackTrace(a);
undefined;
> console.log(a.stack)
Error
at repl:1:7
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined
> function b() { Error.captureStackTrace(a); }
undefined
> b()
undefined
> console.log(a.stack)
Error
at b (repl:1:30)
at repl:1:1
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
undefined
> function b() { Error.captureStackTrace(a, b); }
undefined
> b()
undefined
> console.log(a.stack)
Error
at repl:1:1
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined

StackTraceAPIに書いてあるまんまの機能です。さて、ここからが本題ですが、captureStackTraceがstackプロパティを付け足してくれるものであることは分かりました。では、ここからがWikiに書いていないことですが、Error.prepareStackTraceの一番最初の行の書式化はどのように行っているのでしょうか?実はとても単純なんですが、次のようになっています。

> var a = { name: 'Yeah' };

undefined
> Error.captureStackTrace(a);
undefined
> console.log(a.stack)
Yeah
at repl:1:7
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined
> var a = { message: 'Message' };
> Error.captureStackTrace(a);
undefined
> console.log(a.stack)
Error: Message
at repl:1:7
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined
> var a = { name: 'Yeah', message: 'Message' };
undefined
> Error.captureStackTrace(a);
undefined
> console.log(a.stack)
Yeah: Message
at repl:1:7
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined

はい、nameが何も無い時はエラー名がデフォルト値Errorに、messageが何も無い時はコロン以降を表示しないというわけです。

このError.captureStackTraceをコンストラクタに仕込めば、newされた時スタックのキャプチャが走り、スタックトレースがstackプロパティに入る仕組みを作ればいいわけですね。現にErrorコンストラクはそのような仕様になっています。

> function a() { return new Error('error'); }

undefined
> let err = a();
undefined
> function b() { throw err; }
undefined
> b();
Error: error
at a (repl:1:31)
at repl:1:11
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)

関数bでエラーを投げても、スタックトレースは関数a始まりであることがわかりますね。これによって、Promiseのrejectなどでも、エラーが発生した箇所をきちんとキャプチャできるわけです。

さて、これでStackTraceをちゃんと表示するための材料は揃いました。


どのようなオブジェクトがNodeのエラーと言えるのか

さて、スタックトレースに関しては、どのように改善を加えればいいか検討の余地がつきました。しかし、スタックトレースがそれっぽくなるだけではNodeのエラーとは言えません。Nodeのエラーには他に幾つか満たしておきたい条件があります。

元ネタのGistであげられているテストの要件です。


  • Errorのインスタンスであること: assert(err instanceof Error)

  • NodeのisErrorを満たすこと: assert(require('util').isError(err))

スタックトレースの表示がちゃんとしてることと合わせて、ちょうど語呂の良い三要件になりそうですね。では、まずNode.js上にこの条件を満たすエラークラスを実装してみましょう。

// errorbase.js

class ErrorBase extends Error {
constructor(message) {
super();
Object.defineProperty(this, 'name', {
get: () => this.constructor.name,
});
Object.defineProperty(this, 'message', {
get: () => message,
});
Error.captureStackTrace(this, this.constructor);
}
}

definePropertyはconsole.logの表示を綺麗に見せるおまじないです。もし、definePropertyを使わないで、直接this.nameなどに代入してしまうと、デフォルトでenumerableという属性が入ってしまってconsole.logなどでプロパティなどが列挙されてしまいます。実際比べてみればわかります。

> console.log(new ErrorBase('message'));

ErrorBase: message
at repl:1:13
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7)
undefined
> class BadErrorBase extends Error { constructor() { super(); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } }
[Function: BadErrorBase]
> console.log(new BadErrorBase());
{ BadErrorBase
at repl:1:13
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:346:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:545:10)
at emitOne (events.js:101:20)
at REPLServer.emit (events.js:188:7) name: 'BadErrorBase' }
undefined

最後のnameのおかげで表示が変になってしまいました。まあ、この機能のおかげでずいぶんデバッグしやすくなったんですけどね。まあ、とにかくおまじないです。

併せて、テストコードも書いておきましょう。

const {

ErrorBase,
} = require('./errorbase.js');

const assert = require('assert');
const util = require('util');

function it(_, f) {
f();
}

function assertNodeError(err) {
assert(err.name === err.constructor.name);
assert(err instanceof Error);
assert(err instanceof ErrorBase);
assert(util.isError(err));
assert(err.stack !== undefined);
assert(err.stack.split('\n')[0] === err.toString());
}

it('should be an error', () => {
function doSomethingBad() {
throw new ErrorBase('error');
}

try {
doSomethingBad();
} catch (err) {
assertNodeError(err);
assert(err.name === 'ErrorBase');
assert(err.toString() === 'ErrorBase: error');
assert(/ at doSomethingBad/.test(err.stack.split('\n')[1]));
}
});

it('should be as an error base', () => {
class AnError extends ErrorBase {
constructor(message, extra) {
super(message);
this.extra = extra;
}
}

function doSomethingBad() {
throw new AnError('error', 0);
}

try {
doSomethingBad();
} catch (err) {
assertNodeError(err);
assert(err.name === 'AnError');
assert(err.toString() === 'AnError: error');
assert(/ at doSomethingBad/.test(err.stack.split('\n')[1]));
assert(err.extra === 0);
}
});


TypeScriptに書き直す

Notice: 動かなくなったので、最後に追記しました。そちらもご覧ください。

では、いよいよ本題。今までは、JavaScriptで書いてきたわけですが、どうせならTypeScriptで書きたいですよね。ということで、TypeScriptで同等のものを書いてみましょう。

まず下準備として、nodeの型定義ファイルもインストールしておきましょう。

// errorbase.ts

export class ErrorBase extends Error {
public constructor(message: string) {
super();
Object.defineProperty(this, 'name', {
get: () => (this.constructor as any).name,
});
Object.defineProperty(this, 'message', {
get: () => message,
});
Error.captureStackTrace(this, this.constructor);
}
}

待って:raised_hand: ほら、ちゃんとTypeScriptらしく型キャストとかしてるやん?はい、もうJavaScriptまんまですね。デコレータとか使えばenumerableを無効にできるんですけどねー。でも基本デコレータはパフォーマンス劣化ですし、メソッドとかアクセッサーにしか使えませんし、型キャストの方がお得ですし。今回の場合、Error型にname/message/stackの型定義が入ってるので、そのまま使えます。

ついでにテストもTypeScriptで書き直しておきます。

import {

ErrorBase,
} from './errorbase';

import * as assert from 'assert';
import * as util from 'util';

function it(_: string, f: () => void) {
f();
}

function assertNodeError(err: any) {
assert(err.name === err.constructor.name);
assert(err instanceof Error);
assert(err instanceof ErrorBase);
assert(util.isError(err));
assert(err.stack !== undefined);
assert(err.stack.split('\n')[0] === err.toString());
}

it('should be an error', () => {
function doSomethingBad() {
throw new ErrorBase('error');
}

try {
doSomethingBad();
} catch (err) {
assertNodeError(err);
assert(err.name === 'ErrorBase');
assert(err.toString() === 'ErrorBase: error');
assert(/ at doSomethingBad/.test(err.stack.split('\n')[1]));
}
});

it('should be as an error base', () => {
class AnError extends ErrorBase {
public extra: number;

constructor(message: string, extra: number) {
super(message);
this.extra = extra;
}
}

function doSomethingBad() {
throw new AnError('error', 0);
}

try {
doSomethingBad();
} catch (err) {
assertNodeError(err);
assert(err.name === 'AnError');
assert(err.toString() === 'AnError: error');
assert(/ at doSomethingBad/.test(err.stack.split('\n')[1]));
assert(err.extra === 0);
}
});

完成です:tada:


補足

ところで、適当に流していましたが、なぜclass ErrorBase extends Error {}ではダメなのでしょう?Error内部でcaptureStackTraceが走っているなら、このコードは問題ないはずです。これは、おそらく特定の人たちは知っていると思うのですが、Errorの実装の仕方が問題となっています。

http://www.ecma-international.org/ecma-262/5.1/#sec-15.11.1

はい、これを読めばわかりますが、JavaScriptではError(...)というのはnew Error(...)と完全に等しいんですね。super(...)というのは通常スーパークラスのコンストラクタにthisを渡して呼ばれます。しかしながら、Errorの場合、new Error(...)と一緒ですから、super(...)は新たなErrorオブジェクトが生成されて返ってくることになるのです。もちろん、それらはどこからも参照されず破棄されます。

new忘れてthrowする輩がたくさんいた時代があったんでしょうね。僕は若いのでそんな時代知らないなー :rolling_eyes: まあ、TypeScriptではextendsした場合superを呼び出すのが必須ですからね。仕方ないですね。

なので、JavaScriptでのカスタムエラーの実装の仕方の通常の正解は、https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types になります。ほんと最高の言語ですね。

今回のv8での話と合わせれば、v8での特殊処理を入れて次のようにしとくのも良いかもしれません。

function MyError(message) {

Object.defineProperty(this, 'name', {
get: () => (this.constructor as any).name,
});
Object.defineProperty(this, 'message', {
get: () => message,
});
if (typeof Error.captureStackTrace === 'function') {
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = (new Error()).stack;
}
}
MyError.prototype = Object.create(Error.prototype);
MyError.prototype.constructor = MyError;


最後に

今回は、TypeScriptでNodeのカスタムエラーを作成する方法を紹介しました。TypeScriptの話はちょっとしかなくてすいません。

最後に、もう一度MDNのドキュメントの引用文を貼っておきます。


非標準

この機能は標準ではなく、標準化の予定もありません。公開されている Webサイトには使用しないでください。ユーザーによっては使用できないことがあります。実装ごとに大きな差があることもあり、将来は振る舞いが変わるかもしれません。


はい、というわけでNodeユーザーには身近な存在であるスタックトレースも、JavaScript全体から見れば標準ではありません。そして、Nodeはv8を使用していますから、StackTraceAPIがv8の仕様である以上、v8の仕様が変われば動作も変わるでしょう。

スタックトレースはディベロッパにとって欠かせない存在ですが、標準じゃ無い以上使い方には気をつける必要があります。その辺を踏まえてうまく付き合っていきましょうね。


P.S.

この記事は、TypeScript2.0以前向けに書いた記事なんですが、TypeScript2.1ではこれが入ったおかげで、早速動かなくなりました。

原因は、補足で説明した、Errorの取り扱いが原因なのですが、僕はとりあえず色々諦めて現在以下のように修正したコードを使っています。(つ、つれえ・・・)

// errorbase.ts

export interface ErrorBase extends Error {
readonly name: string;
readonly message: string;
readonly stack: string;
};
export interface ErrorBaseConstructor {
new (message: string): ErrorBase;
readonly prototype: ErrorBase;
}

export const ErrorBase: ErrorBaseConstructor = <any>class ErrorBase {
public constructor(message: string) {
Object.defineProperty(this, 'name', {
get: () => (this.constructor as any).name,
});
Object.defineProperty(this, 'message', {
get: () => message,
});
Error.captureStackTrace(this, this.constructor);
}
};
(ErrorBase as any).prototype = Object.create(Error.prototype);
ErrorBase.prototype.constructor = ErrorBase;

何処のJavaScriptでしょうね・・・ :innocent: い、いちよTypeScriptでもコンパイルできるから!





  1. v8のStackTraceAPIは結構コードが遷移しています。この記事では、Node7、つまり、大体v8の5以降のお話をします。悪しからず。もちろん、この記事が書かれた時点ではNode7が最新だったわけですが、この記事を読んでいるあなたの時代にどうなっているかは私には分かりません。それを踏まえてお読みください。