少し前にTypeScriptを勉強していて、この型システムだと実装を隠蔽した型というものをうまく表現できてないな、と思ったことがあったのでメモしておく。
TypeScriptはJavaScriptに型情報を与えられるように拡張した(それ以外にもいろいろ拡張のある)言語だけど、JSらしいゆるさを残した仕様になっていて、基本的には構造の同じものは全て受け入れるようになっている(Structural subtypingという)。
interfaceという定義がGoのinterfaceのような感じになっていて、継承関係は全くなくても同じインタフェースのオブジェクト(ようは特定のフィールドを持つオブジェクト)であればコンパイラは通してくれる。
interface Foo {
foo: string;
}
function foo(f: Foo) {
console.log(f.foo);
}
foo({foo: 'foo'}); // OK
foo({foo: 1}); // NG: foo is not string
foo({bar: 'foo'}); // NG: interface mismatch
で、世の中には実装を隠蔽したいことがあるのと、TypeScriptだけではうまく完結しないものがある。ある種のファクトリーから生成されるオブジェクトだけを受け入れたい、とか。Node.JSのCモジュールが関連するオブジェクトなので内部実装がJS/TSでは表現不可能だとか。
こういう状況をTypeScriptではうまく表現できないように思える。
内部的なデータ構造は表現できないので、
interface Foo {
}
のように書くと、Foo型の変数にはおおむねどんな値でも代入できてしまう(指定されている構造に一致するため)。そこで本当には存在しないけれども、内部的に何らかのものを持っていますよ、ということを意味する適当なフィールドを入れてみる。
interface Foo {
_impl: ...
}
だがこの場合でも、_implの型はなんであれ、その値を適当に作って _impl に入れることで簡単にこのインタフェースを実現できてしまう。
まあ実用上はコメントでも書いておけばよいのだろうけれど……。
なお、TypeScriptにはクラスもあるが、クラスもインタフェースが一致していれば通すためこの目的には合致しない。
class Foo {
foo() {
console.log('foo');
}
}
function fooer(f: Foo) {
f.foo();
}
class Bar {
foo() {
console.log('bar');
}
}
fooer(new Foo()); // No problem
fooer(new Bar()); // OK, Bar is compatible with Foo
ただ、プライベートなフィールドを勝手に設定すると、この問題は解消できる。
class Foo {
private _impl: any;
foo() {
console.log('foo');
}
}
function fooer(f: Foo) {
f.foo();
}
class Bar {
private _impl: any;
foo() {
console.log('bar');
}
}
fooer(new Foo()); // No problem
fooer(new Bar()); // NG
これはTypeScriptではprivateなフィールドは互換でないと判断されるからだ。
ただしこの手法の場合でも、Fooに相当するクラスが(TypeScriptレベルでは)実在してしまうため、通常のクラス操作であるnewもできるし、Fooを継承したサブクラスも定義できる。Cモジュールなどから得られるデータ型はたいてい何らかのファクトリー関数から生成されるので、データ構造がうまくあわなくなることがある。けっきょく、こういう状況をTypeScriptで表現するのは難しい、ということだ。
……という理解だったのだけど、この記事の草稿を書いてから今に至るまでにTypeScript 2.0がリリースされて、この問題は解決された。
どういうことかというと、TypeScript 2.0からprivateなconstructorを作れるようになった。これにより、外からnewできないクラスというものが定義できるようになった。そしてprivateなconstructorを持つクラスは継承できないとされた。
class Foo {
private foo: any;
private constructor() {}
static create(): Foo { return new Foo(); }
}
function foo(f: Foo) {
console.log(f);
}
foo(Foo.create()); // OK
foo(new Foo()); // NG: private constructor
foo({foo: 1}); // NG: 'foo' should be private
// NG: cannot extend a class with private constructor.
class Bar extends Foo {}
というわけで、こういうクラス宣言により、うえで書いたような状況はうまく表現できるようになった。現実にはd.tsファイルにこういう定義を書いておき、内部実装はJSとCモジュールに適宜実装されることになる。
それにしてもプライベートなコンストラクタというのは、コンパイルされるとどうなるのだろうか。クラス名が手に入ればJSレベルではnewできるんだけど…………と思ったのだけど、どうもTypeScriptコンパイラは可視性の情報を落とすだけなようなので、JSからは普通にnewできるクラスに見えるらしい。まあそりゃそっか、という話であるし、それでいいよなという話でもあるのだった。