皆さんこんにちは。この記事では、TypeScriptにおいて型レベル連結リストが役に立つ一例をご紹介します。当初以下のように練習問題の形でツイートしたところ、型レベル連結リストを用いる想定解にたどり着いた方がいました。おめでとうございます。
TypeScript練習問題: このようなContainer型をつくれ(ぇ https://t.co/zq25GiXDM2 pic.twitter.com/llveS6MCjG
— 🈚️うひょ🤪✒📘 (@uhyo_) February 1, 2020
型レベル連結リストとは
連結リストはリストの表現方法の一種です。連結リストではリストの各要素が「自身の値」と「次の要素への参照」(あるいは次の要素そのもの)を保持しています。リストの各要素には、先頭要素から順番にたどることでアクセスできます。
型レベル連結リストでは、連結リストの構造を型として表現します。型レベルということで、型レベル連結リストの要素は型です。例えばnumber
, string
, number
の3要素からなる型レベル連結リストは、TypeScriptで次のように表現できます。
type L = {
value: number;
next: {
value: string;
next: {
value: number;
}
}
};
一つのオブジェクト型が連結リストの1要素です。要素はvalue
プロパティで自身の要素を保持し、またnext
プロパティで次の要素を保持します。最後の要素はnext
プロパティを持ちません。
この表現だと0要素の連結リストを表現する方法がありませんが、今回は特に問題ではありません。
型レベル連結リストの利点
型レベル連結リストをこのように表現した場合、大きな利点が一つあります。それは、前方一致関係と部分型関係が一致するということです。具体的に言えば、型レベル連結リストS
とT
について、S
がT
のprefixである(T
の最初のいくつかの要素を取り出すとS
になる)ならば、T
はS
の部分型となります。
具体例を見てみましょう。次の例ではS
はnumber
, string
からなる2要素のリストであり、T
はnumber
, string
, number
からなる3要素のリストあり、S
はT
に前方一致しています。コード中で確かめられているように、T
はS
の部分型となっています。
type S = {
value: number;
next: {
value: string;
}
};
type T = {
value: number;
next: {
value: string;
next: {
value: number;
}
}
};
// TがSの部分型であることを確かめる
declare const t: T;
const s: S = t;
ちなみに、タプル型を用いて型レベルリストを[number, string]
とか[number, string, number]
のように表現した場合はこの性質が成り立ちません。これら2つの型は非互換なlength
プロパティを持つからです(前者のlength
プロパティは2
型を持ち、後者は3
型を持ちます)。
型レベルリストが欲しい場合ほとんどの場合はタプル型を用いた方法で十分ですが、「前方一致関係と部分型関係が一致する」という性質が必要な場合は今回紹介した型レベル連結リストの手法が役に立ちます。
そして、ここからはこの性質が実際に活きる場合を紹介します。それが、冒頭のツイートで述べた「練習問題」です。
練習問題:型を変えられるコンテナを作る
今回の問題は、次のような挙動をするcontainer
を作るというものです。
const container: Container<{ value: number }> = makeContainer(123);
const num: number = container.data;
container.setData("foobar");
const str: string = container.data;
container.setData(true);
const bool: boolean = container.data;
const notNum: number = container.data;
const notStr: string = container.data;
この例では、最初container.data
はnumber
型です。しかし、container.setData
を呼び出して文字列を渡すと、container.data
はstring
型に変わります。さらにcontainer.setData
に真偽値を渡すとcontainer.data
はboolean
型に変わります。
このように、container
はsetData
を呼び出すとそれに応じてcontainer.data
の型が変わってしまいます。このような特性を持つcontainer
を実装するというのが今回の課題です。
素朴な解法とその問題点
今回container.data
の型が変わっているということは、container
の型が変わっているということです。しかし、TypeScriptでは変数の型は宣言時に決まるのが原則であり、(条件分岐で型が絞り込まれる場合を除いて)変数の型が後から変わることは基本的にはありません。
しかし、その原則を破って変数の型を後から変える方法が一つあります。筆者の記事を読んでくださっている熱心な方ならば、以下の記事で紹介したasserts
を使えばいいことはすぐに分かるでしょう。
TypeScript 3.7で導入されたasserts
構文を用いれば、メソッド呼び出しに伴って変数の型を変えることができます。特に、この記事の最後で説明したasserts this is
パターンを用います。これを使って素朴に実装しようとすると、以下のようになるでしょう。
class Container<T> {
data: T;
constructor(data: T) {
this.data = data;
}
setData<U>(newData: U): asserts this is Container<U> {
(this as any).data = newData;
}
}
const makeContainer = <T>(data: T) => new Container(data);
Container<T>
は、data
がT
型のコンテナの型です。setData
の型宣言では返り値をasserts this is Container<U>
としています。これは「container.setData
をU
型の値で呼び出すとcontainer
の型がContainer<U>
型になる」という意味です。
一見これで良さそうに見えますが、実はこれでは問題があります。これを実際に使ってみると思った結果にはなりません。
const container: Container<number> = makeContainer(123);
const num: number = container.data; // container.data は number型
container.setData("foobar");
const str: string = container.data; // container.data は never型
container.setData("foobar")
のあと、container.data
はstring
型になってほしいところですが、実際にはnever
型となってしまいます。
その理由はsetData
呼び出しのあとのcontainer
の型を見てみればわかります。container
の型はContainer<string>
となっていて欲しいところ、実際にはContainer<number> & Container<string>
型となっています。つまり、asserts this is
は型を完全に書き換えるのではなく、元の型とのintersection型が取られるのです。これには、asserts
が行うのはあくまで型の絞り込みであるという考え方が見て取れます。
container
がContainer<number> & Container<string>
型だと、container.data
がnumber & string
型になってしまいます。数値かつ文字列であるような値は存在しませんから、これはnever
型に解決されます。
ということで、Container<T>
を正しく実装するためには、このasserts
の制限に耐える実装としなければいけないことが分かりました。
解決編(1) 型レベル連結リストによる解決
今回問題だったのは、Container<U> & Container<T>
という型が発生すると中身の型がU & T
となってしまうことでした。例えばstring
とnumber
が混ざるとnever
となってしまいます。この問題を回避するためには、&
で合成されても情報が混ざらないような表現方法を考えれば良いですね。そのために型レベル連結リストを用いることができます。
初期状態を{ value: number }
として中身がnumber
型であることを表し、setData("foobar")
のあとは{ value: number; next: { value: string } }
として「最初number
型で次にstring
型になった」ことを表します。
こうすることで、両者が&
で混ぜられたとしても{ value: number } & { value: number; next: { value: string } }
は{ value: number; next: { value: string } }
ですから、number
とstring
が混ざらずに情報が保持されています。ここで記事の最初で紹介した型レベル連結リストの性質が活きています1。
ということで、実際のコードを見てみます。まず2つの補助型関数を定義します。
type Append<List, T> = List extends { value: infer V; next: infer N }
? { value: V; next: Append<N, T> }
: List extends { value: infer V }
? { value: V; next: { value: T } }
: never
type Last<List> = {
n: List extends { next: infer N } ? Last<N> : never;
v: List extends { value: infer V } ? V : unknown;
}[List extends { next: any } ? "n" : "v"];
Append<List, T>
とLast<List>
が型レベル連結リストの操作を担う補助型関数です。Append<List, T>
はリストList
の最後にT
を追加した新しいリストを返します。例えばAppend<{ value: number }, string>
は{ value: number; next: { value: string } }
となります。最後に追加するという操作なので$O(N)$となりますが、仕方ありません。
また、Last<List>
は与えられたリストの最後の要素を返します。例えばLast<{ value: number; next: { value: string } }>
はstring
です。こちらも$O(N)$ですね。
これらを用いて、実際のContainer<Data>
の定義は以下のようになります。先の例ではData
はデータの実際の型でしたが、今回は代わりに前述の型レベルリストがここに入ります。よって、初期状態でnumber
が入っているコンテナの型はContainer<{ value: numbner }>
となります。
class Container<Data> {
public data: Last<Data>;
constructor(data: any) {
this.data = data;
}
public setData<T>(value: T): asserts this is Container<Append<Data, T>> {
(this as any).data = value;
}
}
const makeContainer = <T>(data: T) => new Container<{ value: T }>(data)
コードを見るとわかるように、コンテナの実際のdata
プロパティの型はLast<Data>
となります。また、setData
の返り値はasserts this is Container<Append<Data, T>>
となっています。つまり、現在の連結リストに新しく与えられた型を加えて新しい連結リストとしています。
次のコードでこれの動作を確認してみます。最初はData
は{ value: number }
という1要素のリストですが、container.setData("foobar")
のあとはcontainer
の型はContainer<{ value: number; { next: { value: string } } }>
となっています。これによりcontainer.data
の型がstring
となります。
const container: Container<{ value: number }> = makeContainer(123);
const num: number = container.data;
container.setData("foobar");
const str: string = container.data;
container.setData(true);
const bool: boolean = container.data;
同様に、さらにcontainer.setData(true)
とすると、container
の型がContainer<{ value: number; { next: { value: string; next: { value: boolean } } } }>
となります。これで無事にcontainer.data
がboolean
型になりました。
これにて一件落着……と言いたいところですが、ひとつ不可解な点があります。最初の話では、asserts
によって型を変えた場合は元の型とのインターセクションが取られるという話でした。しかし、今回よく見るとそうなっていません。すなわち、container.setData("foobar")
の呼び出しの後、container
が本来ならContainer<{ value: number }> & Container<{ value: number; next: { value: string } }>
になるはずが、&
の左が消えてしまっています。
これは実は、Container<{ value: number; next: { value: string } }>
がContainer<{ value: number }>
の部分型であると判定されていることが原因です。実際にはcontainer.data
の型が違うので部分型ではないのですが、それが無視されています。これは、次のissueで報告されているTypeScriptのバグを踏んでいると考えられます。
ということで、目的は達成しているもののTypeScriptのバグに依存しており何だか釈然としません。また、誤った部分型関係が導かれてしまうということは、Container
の誤った使い方をされると危険なコードを書けてしまうということです。よって、この穴を塞がなければいけません。
解決編(2) 安全性の穴を塞ぐ
安全性の穴を塞ぐには、Data
が違うときにContainer<Data>
が部分型関係を持たないようにしなければいけません。本来はTypeScriptがきちんと判定するべきですが、前述のバグによりTypeScriptは正しくこれを判定することができず、こちらで補助する必要があります。
簡単な方法は、次のように2つのプロパティ_data
と_func
を追加することです。
class Container<Data> {
private _data!: Data;
private _func!: (data: Data) => void;
public data: Last<Data>;
constructor(data: any) {
this.data = data;
}
public setData<T>(value: T): asserts this is Container<Append<Data, T>> {
(this as any).data = value;
}
}
プロパティ_data
は共変の位置にData
を配置し、_func
は反変の位置にData
を配置します。これにより、Data
が一致していない2つのContainer<Data>
型に対して部分型関係が発生することは無くなります。
では、改めて挙動を確かめてみましょう。
const container: Container<{ value: number }> = makeContainer(123);
const num: number = container.data;
container.setData("foobar");
const str: string = container.data;
container.setData("foobar")
の後のcontainer
の型を確かめると、Container<{ value: number }> & Container<{ value: number; next: { value: string } }>
となっており、&
の左が消えなくなりました。
しかし、ここで問題が発生します。container.data
の型を調べるとnever
に戻ってしまっています。一難去ってまた一難ですね。
その理由はよくよく考えてみると分かります。container.data
の型を求める場合、まずcontainer
の型を求めてそのdata
プロパティの型を取ることになります。今回container
の型はインターセクション型ですから、両方のdata
プロパティを取ってインターセクションを求めることになります。そうなると、Container<{ value: number }>
はdata
プロパティがnumber
型であり、Container<{ value: number; next: { value: string } }>
はdata
プロパティがstring
型ですから、それらのインターセクションを取ってnumber & string
となり、これがnever
になります。どうやら最初の問題に戻ってきてしまったようです。
本当は、先に{ value: number }
と{ value: number; next: { value: string } }
のインターセクションを取ってからdata
プロパティの型を計算してほしいところです。それを可能にするのが**this
型**です。
ということで、この問題を解決した最終形を見ましょう。
解決編(3) this
のトリック
いきなりですが、次のようにすれば前述の問題を解決できます。
class Container<Data> {
_data!: Data;
private _func!: (d: Data) => void;
public data: Last<this["_data"]>;
constructor(data: any) {
this.data = data;
}
public setData<T>(value: T): asserts this is Container<Append<this["_data"], T>> {
(this as any).data = value;
}
}
const makeContainer = <T>(data: T) => new Container<{ value: T }>(data)
data
プロパティやsetData
メソッドの型において、Data
を使っていた部分がthis["_data"]
に変わっています。this
型経由で参照されるプロパティはprivate
であってはいけないため_data
の宣言からprivate
が外れました。
なぜこれでうまく行くのかを見ます。container.data
の型を調べるにあたってまずcontainer
の型が調べられるのは一緒です。container
の型はやはりContainer<{ value: number }> & Container<{ value: number; next: { value: string } }>
です。2つのContainer
型についてそれぞれdata
の型が取得されるのも同じです。
今回data
の型はLast<this["_data"]>
ですが、実はこのときのthis
の型は常にcontainer
の型、つまりContainer<{ value: number }> & Container<{ value: number; next: { value: string } }>
です。&
の左側のContainer<{ value: number }>
のdata
型を計算するときであっても、this
の型はContainer<{ value: number }> & Container<{ value: number; next: { value: string } }>
となります。
そして、this
がContainer<{ value: number }> & Container<{ value: number; next: { value: string } }>
というインターセクション型であるとき、その_data
の型は{ value: number } & { value: number; next: { value: string } }
、すなわち{ value: number; next: { value: string } }
となります。
this
のこの特性により、container.data
の型を計算するとき、&
の左でも右でもthis["_data"]
の型が{ value: number; next: { value: string } }
となることから、data
の型はLast<{ value: number; next: { value: string } }>
、つまりstring
型となります。これによりcontainer.data
の型がstring & string
、つまりstring
となります。
まとめると、this
型の特性を用いることでContainer<{ value: number }> & Container<{ value: number; next: { value: string } }>
というインターセクション型を{ value: number } & { value: number; next: { value: string } }
というインターセクション型に変換し、それにより目的の型を得ることができました。
なお、_data
がprivate
でなくなったことで新たな危険性が生じていることに気づいた方もいるでしょう。本質ではないのでここまでの解説では省きましたが、次のようにすれば一応回避できます。
class Container<Data> {
_data?: Data;
private _func!: (d: Data) => void;
public data: Last<NonNullable<this["_data"]>>;
constructor(data: any) {
this.data = data;
}
public setData<T>(value: T): asserts this is Container<Append<NonNullable<this["_data"]>, T>> {
(this as any).data = value;
}
}
まとめ
この記事では、型を変えられるコンテナオブジェクトという題材を通じて、TypeScriptプログラミングのいくつかのテクニックを紹介しました。ひとつは、型レベル連結リストによって型に情報を付加していくテクニックです。記事冒頭で述べた性質により、古いリストと新しいリストを&
で結合しても情報が壊れないという点がポイントです。
また、this
型の性質も大きなポイントです。これによって、2つのContainer
型が&
で分離されているという問題をバイパスすることができます。よく見るとcontainer.data
の型の計算がsetData
した回数$N$に対して$O(N^2)$かかっているような気がしますが、まあ些細な問題ですね。
Q&A
Q. 型を変えられるコンテナなんて実務で使うんですか?
A. さあ……
-
実際のところ、異なる階層にデータを保存していれば混ざらないので型レベル連結リストが唯一の方法という訳でもありません。ここでは汎用性のあるデータ構造として型レベル連結リストを推奨しています。 ↩