LoginSignup
2
2

More than 3 years have passed since last update.

TypeScript でモジュール外では実装できないインターフェースを定義する

Posted at

問題意識

ライブラリを書いていると、このインターフェースは、使い方を公開してるだけで、実装してほしいわけじゃないんだよなあ、というケースが結構ある。

例えば次のような

ライブラリ側
export function createMyNode(): MyNode {
    return new MyNodeImpl();
}

export interface MyNode {
    readonly parent: MyNode | null;
    readonly children: readonly MyNode[];
    append(node: MyNode): void;
}

class MyNodeImpl implements MyNode {
    private _parent: MyNodeImpl | null = null;
    private _childern: MyNodeImpl[] = [];
    get parent() { return this._parent; }
    get children() { return this._childern; }

    append(node: MyNode) {
        if (!(node instanceof MyNodeImpl)) {
            throw new Error('oops!');
        }
        if (node._parent === null) {
            node._parent = this;
            this._childern.push(node);
        }
        // ... 略
    }
}

このコードは、次のような誤った使い方を静的に防げない。

利用者側
const node = createMyNode();
node.append({
  parent: null,
  children: [],
  append(node) { this.children.push(node); }
});

append に適当に MyNode を実装したオブジェクトを突っ込むことができてしまうけれど、それを受け取ったところで正常に処理はできない。

コードにあるように、チェックして実行時にエラーを出すことはできるけれど、実行時にコストがかかるし、あんまりうれしくない。

インターフェースじゃなくてクラスにする手は完全には解決にならないし、他の問題を色々と呼び込むので、それはそれで避けたいところ。

せっかくのTypeScriptなのだし、型で防ぎたいよね。

と、いうわけで、

実装できないインターフェースを書く

ライブラリ側・改
export function createMyNode(): MyNode {
    return new MyNodeImpl();
}

declare const seal: unique symbol; // (1)
export interface MyNode {
    [seal]: undefined; // (2)
    readonly parent: MyNode | null;
    readonly children: readonly MyNode[];
    append(node: MyNode): void;
}

class MyNodeImpl implements MyNode {
    [seal]: undefined; // (3)
    private _parent: MyNodeImpl | null = null;
    private _childern: MyNodeImpl[] = [];
    get parent() { return this._parent; }
    get children() { return this._childern; }

    append(node: MyNodeImpl) { // (4)
        if (node._parent === null) {
            node._parent = this;
            this._childern.push(node);
        }
        // ... 略
    }
}

(1)
モジュールの中で symbol を宣言する。export はしない。
型システム上で防ぐだけなので、symbol の実体は要らない。アンビエント宣言で済ませる。

(2)(3)
それをインターフェースと実装クラスに、それぞれプロパティとして追加。
値も要らないので型は undefined にしておく(neverでもいいかも)。
初期化は要らない。javascript に変換されれば、追加部分は跡形もなくなる。

(4)
最後に、引数はインターフェースではなく実装クラスで受ける。
instanceof でのチェックは無くて良い。
一般的には危険だけれど、今回のケースでは MyNode の実装は MyNodeImpl 以外に存在しないのでセーフ。
TypeScript の引数は bivariant なので、この書き方自体には問題がない。

さて、これでモジュール外では、(普通の方法では) MyNode を実装できない。
実装するには seal を得る必要があるが、

利用者側・改
const node = createMyNode();
declare const seal: unique symbol;
node.append({
    [seal]: undefined, // ここでエラー
    parent: null,
    children: [],
    append(node) { this.children.push(node); }
});

同じ名前を付けたところで、それは別のシンボルだ。
tsc はこれをちゃんと弾いてくれる。

めでたしめでたし。

抜け道とその対処

「(普通の方法では)」と書いたように、この方法は抜け道がある。

利用者側・改改
const node = createMyNode();

type GetSymbol<T> = {
    [S in keyof T]: S extends symbol ? S : never
}[keyof T];

declare const seal: GetSymbol<MyNode>;
class HackNode implements MyNode {
    [seal]: undefined;
    parent: MyNode | null = null;
    children: MyNode[] = [];
    append(node) { this.children.push(node); }
}
node.append(new HackNode());

それがインターフェース内で唯一の symbol キーならば、抽出できてしまう。
どうしても対抗したければ、こう。

ライブラリ側・改改
declare const seal1: unique symbol;
declare const seal2: unique symbol;
export interface MyNode {
    [seal1]: undefined;
    [seal2]: undefined;
    // 略
}
class MyNodeImpl implements MyNode {
    [seal1]: undefined;
    [seal2]: undefined;
    // 略
}

安直に二つにする。

この場合、GetSymbol<MyNode>unique symbol | unique symbol になるので、一つに絞り込めなくなり上記の方法でも実装できなくなる(何か面白い抜け道あったら教えてください)。

まあ、普通は実装のためにシンボルを引っこ抜こうとか考えないので、利用者の間違いを防ぐ意図なら、二つに増やす対処をする必要は無いように思う。
強引に異なるオブジェクトを突っ込みたいなら、そもそも as any で行けてしまうのだし。

2
2
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2