🕌

Deno(Nodeでも)で外部モジュールのクラスやSymbolを使用するときは少し慎重になる必要がある

commits6 min read

あるモジュールから引っ張ってきたオブジェクトはバージョン違いや URL の hash が違うだけで異なるオブジェクトになります、という話をします。

問題点

Error のサブクラスを使う例を考えます。

class MyError extends Error {/* ... */}

Error のサブクラスをちゃんと書こうと思うと結構大変なので、Qiita: JavaScript でカスタム例外をしっかり使うそこに書いている私のコメント などを参照してください。

このとき、他のモジュールから持ってきた Error サブクラスは、本当に判定したいと思っているクラスであるかどうかを一考する必要があります。

deno-unknownutil という実際に存在するモジュールを使って説明していきます。以下のコードは実行できるようにしています。 ( deno v1.14.3 で確認 )

// mod1.ts
import { ensureString } from "https://deno.land/x/unknownutil@v1.1.0/mod.ts";

export const myCoolHandler = (get: () => unknown): string => {
  const v = get();
  ensureString(v);
  return v.substring(1);
};
// mod2.ts
import { EnsureError } from "https://deno.land/x/unknownutil@v1.1.3/mod.ts";
import { myCoolHandler } from "./mod1.ts";

try {
  myCoolHandler(() => "1");
  myCoolHandler(() => 1);
} catch (e: unknown) {
  if (e instanceof EnsureError) {
    console.log("EnsureError です!");
  } else {
    console.log("しらないエラーです!");
  }
}

実際には mod1.ts は他の人が作ったモジュールだと考えてください。

さて、mod2.ts を実行してみます。

> deno run ./mod2.ts
しらないエラーです!

EnsureError が補足できていません。これはバージョン違いのオブジェクトは別モジュール扱いになるためです。

このケースだけ見るとバージョンを合わせれば良い話に思えるかもしれませんが、実際は複数のバージョン、どのバージョンが使われているかわからない可能性があります。

import { EnsureError as EnsureError110 } from "https://deno.land/x/unknownutil@v1.1.0/ensure.ts";
import { EnsureError as EnsureError110X } from "https://deno.land/x/unknownutil@v1.1.0/ensure.ts#^";
import { EnsureError as EnsureError111 } from "https://deno.land/x/unknownutil@v1.1.1/ensure.ts";

console.log(EnsureError110 === EnsureError110); // true
console.log(new EnsureError110() instanceof EnsureError110); // true

console.log(EnsureError110 === EnsureError110X); // false
console.log(EnsureError110 === EnsureError111); // false
console.log(new EnsureError110() instanceof EnsureError111); // false

#^ のような hash は deno-udd などの deno 以外のツールに情報を伝えるためにしばしば使用されます。これも同様に異なるモジュール扱いになります。

混乱するかもしれないので次のケースは省きました。

import { EnsureError as EnsureError110 } from "https://deno.land/x/unknownutil@v1.1.0/ensure.ts";
import { EnsureError as EnsureError110Y } from "https://deno.land/x/unknownutil@v1.1.0/mod.ts#^";

console.log(EnsureError110 === EnsureError110Y); // true

これは https://deno.land/x/unknownutil@v1.1.0/mod.ts

export * from "./is.ts";
export * from "./ensure.ts";

となっており、 resolve されたときに同じ URL になるためです。 (同一URLのモジュールはキャッシュされます)

問題とならないケース

自分で投げて自分で回収するケース

import {
  deadline,
  DeadlineError,
} from "https://deno.land/std@0.109.0/async/mod.ts";

const myHeavyProc = () => new Promise((r) => setTimeout(r, 2000));

try {
  await deadline(myHeavyProc(), 1000);
} catch (e: unknown) {
  if (e instanceof DeadlineError) {
    console.log("DeadlineError です!");
  } else {
    console.log("しらないエラーです!");
  }
}

このような書き方をする場合、自分で投げて自分が確実に回収するという設計になっているので、他のモジュールの DeadlineError について知る必要がありません。 (もし myHeavyProcDeadlineError を投げていたら、バージョン違いに関わらず、myHeavyProcdeadline の使い方に誤りがあると考えられます)

対処法

基本的には使用者側ではどうしようもないので、クラス・Symbol を提供するモジュール作成者が考慮する必要があります。

以下はすべて MyError extends Error をどう公開するか、という例で書いています。

recommended は個人の感想です。

バージョンを固定する

まず、エラーを inernal/my_error.ts などで export し、その変更だけのリリースを作ります。v1.2.3 とします。

その後、 deps.tsmod.tsexport { MyError } from "https://example.com/my_mod@v1.2.3/internal/my_error.ts" としておきます。

internal とするのは、hash 違いでの差異を防ぐためです。

デメリット

  • 一度、なにもないリリースが必要。
  • エラークラスへの変更をしにくい。
  • 配信サーバのハードコーディング
  • (カスタムクラスの数だけファイルが増える)

変更するごとに一個前のクラスを継承する

// v1.2.3
export class MyError extends Error {}
// v1.2.4
export { MyError } from "https://example.com/my_mod@v1.2.3/errors.ts";
// v1.2.5
export { MyError } from "https://example.com/my_mod@v1.2.3/errors.ts";
// v1.3.0
import { MyError as MyErrorOld } from "https://example.com/my_mod@v1.2.3/errors.ts";
export class MyError extends MyErrorOld {/* 新機能 */}
// v1.4.0
import { MyError as MyErrorOld } from "https://example.com/my_mod@v1.3.0/errors.ts";
export class MyError extends MyErrorOld {/* またまた新機能 */}

デメリット

  • 2回目でのバージョン pin が必要
  • 運用方法を意識するのが大変
  • 配信サーバのハードコーディング

ヘルパー関数を用意 (recommended)

isMyError(v: unknown): v is MyError という関数を用意します。

MyError には適当に判定用のメンバをはやしておきます。 name でも良いと思います。なお、 Symbol は結局同じ問題が起こるため、文字列key、リテラル値に頼る事になり、結局、名前空間の奪い合いになります。

(蛇足) 過去のすべてのバージョンと比較する実装にすると、正確ではありますが、たとえ internal にして hash 違いの問題をクリアしたとしても、メンテが大変です。「配信サーバのハードコーディング」というデメリットも生じます。

Symbol の方の問題に関しては、Symbol は module から export するなという事になります(実際多くのケースでそうだと思います)。

デメリット

  • 結局、名前空間の奪い合いになる

クラスを使わない (recommended)

Error のサブクラスはこうもいかないかもしれませんが、ファクトリ関数と duck typing ( interface {...}type T = {...} ) を積極的に採用するのはいいことだと思います。

結局一個上の解決方法もそういうことを言っているようなものです。Template Literal Types や Type Narrowing の強化など、 TypeScript は明らかにこちらの方法を想定した強化をする動きを見せていると思います。

Node.js での事情

npm, yarn, pnpm いずれでもバージョン要求満たせない場合は別のバージョンがネストされて入ります。

これにより、バージョン違いがより発生しえます。 それがなくとも、 Zenn: TypeScript 4.5でますます便利に! better-typescript-lib v2 でも少し話題になりましたが、エイリアスを使うことで同一パッケージの、複数のバージョンを入れることは可能です。

これにより顕在化する、 node での同様の問題はあるかもしれません。ただ、この問題が発生しうる場合、これに対処する義務は deno と違ってモジュールの使用者にあるのだと思います。 (モジュールのバージョン揺れは自分が lockfile で把握している範囲でしか起きないのでそれらを合わせるか、すべてのバージョンのクラスを import して検証する)

GitHubで編集を提案

Discussion

ログインするとコメントできます