問題意識
ライブラリを書いていると、このインターフェースは、使い方を公開してるだけで、実装してほしいわけじゃないんだよなあ、というケースが結構ある。
例えば次のような
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
で行けてしまうのだし。