Conditional Typesで型安全なルーターを作る
(右の書類アイコンのメニューから"start presentation"を選択するとスライドモードになります)
自己紹介
岩本 海童 (いわもと かいどう)
ZOZO研究所というところで、機械学習やったりフロントエンドやったりしてます
今日する話
型安全なルーターについて
おことわり
全体的にコード多めです
間違いの指摘やコメント、突っ込みは歓迎です
Conditional Typesとは何か
名前の通り、条件的な型
T extends U ? X : Yのように書く
TがUに代入可能ならX、そうでない場合はYと評価される
代入可能かどうかの例
string[]はany[]に代入可能
numberは() => voidに代入不可能
T extends Uは、型パラメーターの制約でもおなじみ
type IsString<T> = T extends string ? "yes" : "no" のように型パラメーターを使うことももちろん可能
Conditional Typesの条件式は分配される
T extends U ? X : Yにおいて、TがA | B | Cだった場合
(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)と評価される
ただし、extendsの前に書かれているのが単なる型変数だけである場合のみ
で、何が嬉しいの?
例: union型の中から、条件に一致する(または一致しない)型を抽出する
code:ts
type Filter<T, U> = T extends U ? T : never
type Diff<T, U> = T extends U ? never : T
type T1 = () => void | (n: number) => void | {ok: boolean}
type T1_ = Filter<T1, Function> // () => void | (n: number) => void
type T2 = 'A' | 'B' | null
type T2_ = Diff<T2, null> // 'A' | 'B'
ちなみに、任意の型Tについて、T | neverはTになる (モノイドだ!)
例: string literal typesかどうかの判定
code:ts
// 素朴なやつ
type IsStringLiteral1<S extends string> = string extends S ? false : true
// union typesを分配するバージョン
type IsStringLiteral2<S extends string> = S extends string
? string extends S ? false : true
: never
type T1 = IsStringLiteral1<'a'> // true
type T2 = IsStringLiteral1<'a' | 'b'> // true
type T3 = IsStringLiteral1<'x' | string> // false
type T3_ = IsStringLiteral2<'x' | string> // true | false
Conditional Typesにおける型推論
extendsの右側の型は、完全に決定していなくて良く、一部を推論させることもできる
推論させたい部分の型をinfer Tと書くとその型が推論され、?の次の式でTを参照することができる
code:ts
type ElementOfArray<T> = T extends Array<infer S> ? S : never
type T1 = ElementOfArray<number[]> // number
type T2 = ElementOfArray<number[] | string[]> // number | string
type T3 = ElementOfArray<Function> // never
蛇足: Conditional Typesって、型のパターンマッチじゃない?
というのをある日思いました
ScalaとかSwiftとかHaskellとか書いたことがある人は、そう考えるとスッと理解できるかも?
- - - Conditional Typesの話はここまで。ここからはルーターを作る話 - - -
ルーターとは
次の2つの関数からなるもの(ということにする)
1. ルーティングの定義とパス文字列から、ルート名とパラメーターのオブジェクトを返す
2. ルーティングの定義とルート名とパラメーターのオブジェクトから、パス文字列を返す
ブラウザAPIとかビューとは分離させる
型安全ルーターとは
正しいルート名とパラメーターオブジェクトの組み合わせが型で決まっている(ということにする)
こんなものが欲しい: ルーティングの定義
code:txt
home => /
userDetail => /users/:screenName
tweetDetail => /tweets/:screenName/:tweetId(digits)
favorites => /favorites
こんなものが欲しい: ルートメイトパラメーターオブジェクトの組み合わせ
code:ts
type Routes =
| {name: "home"; params: {}}
| {name: "userDetail"; params: {screenName: string}}
| {name: "tweetDetail"; params: {screenName: string; tweetId: number}}
| {name: "favorites"; params: {}}
| null
TypeScriptでどう書こうか...?🤔
文字列で"/users/:screenName"とか書いちゃうと、パラメーターの情報を型として取り出せない... どうしよう...
そこで、tagged template literal!!
ECMAScriptの構文で、mytag\`aaa ${x} bbb ${y}\`みたいなやつ
例えば、styled-componentsで使われている
code:ts
const MyH1 = styled.h1`
color: ${props => props.color || "#000000"}
`
なんかいけそうな予感...
tagged template literalについて
mytag\`aaa ${x} bbb ${y}\`と書いたとき
mytagの部分は関数を表す式(であれば何でもよい)
mytagの表す関数
引数は
(1) 分割された文字列の配列で、上の例だと["aaa ", " bbb ", ""]
それ以降の引数には、埋め込まれた値(上の例だとx, y)が順番に与えられる
戻り値は
任意のオブジェクト → 文字列でなくても良い!! (styled-componentsが良い例!)
話を戻して...
tagged template literalを使って、こんな風に書けそう
code:ts
Route("home")/,
Route("userDetail")/users/${S("screenName")},
Route("tweetDetail")/tweets/${S("screenName")}/${N("tweetId")}},
Route("favorites")/favorites,
)
Route関数は、文字列を受け取って関数を返す → 返された関数がtagged string literalのタグになる
パラメーターのプレースホルダーを書くのが少しだるいが、許容範囲ということで
S\`screenName\`やN\`tweetId\`と書けると良いのだが、文字列リテラル型として扱えないのでアウト...
さて、Route関数の実装を...
その前に、SとNの実装を
S, Nはそれぞれ文字列型、数値型のパラメーターのプレースホルダーオブジェクトを作る関数
code:ts
type PHValueTypes = 'string' | 'number'
// プレースホルダーの型
interface Placeholder<Type extends PHValueTypes = PHValueTypes, Name extends string = string> {
type: Type
name: Name
}
function S<Name extends string>(name: Name): Placeholder<'string', Name> {
return {type: 'string', name}
}
function N<Name extends string>(name: Name): Placeholder<'number', Name> {
return {type: 'number', name}
}
Route関数
code:ts
interface RouteType<Name extends string, Phs extends ReadonlyArray<Placeholder>> {
name: Name
pattern: RegExp
strings: TemplateStringsArray
placeholders: Phs
}
function Route<Name extends string>(
name: Name,
): <Phs extends ReadonlyArray<Placeholder> = readonly never[]>(
strings: TemplateStringsArray,
...placeholders: Phs
) => RouteType<Name, Phs> {
return (strings, ...values) => {
// stringsやvaluesを使ってURLのパターンを生成する
let s = ''
// ...
const pattern = new RegExp(s)
return {name, pattern, strings, placeholders}
};
}
次に、defineRoutesの実装を...
code:ts
function defineRoutes<Routes extends ReadonlyArray<RouteType<string, never[]>>>(
...routes: Routes
) {
// 指定されたすべてのRouteTypeオブジェクトのunion types
type RouteTypes = Routesnumber // 指定されたすべてのルート名のunion types
type RouteNames = RouteTypes'name' // ルート名がキーでRouteTypeオブジェクトが値になっているオブジェクト
const routesByName: RoutesByName = {} as RoutesByName
for (const route of routes) {
}
// ...
}
Conditional Typesまだー?
もう少しです...!
matchedRoutesの仕様
型: function matchedRoutes(path: string): AllMatchedRoutesOrNull
パス文字列を与えると、マッチするルートを返す
マッチするルートがない場合はnullを返す
AllMatchedRoutesOrNullは、先ほどの例だとこんな感じ
code:ts
type AllMatchedRoutesOrNull =
| {name: "home"; params: {}}
| {name: "userDetail"; params: {screenName: string}}
| {name: "tweetDetail"; params: {screenName: string; tweetId: number}}
| {name: "favorites"; params: {}}
| null
ルーティングの定義からこの型lを作るのに、Conditional Typesが使えそう...!!
urlForRouteの仕様
型: function urlForRoute<Name extends RouteNames>(name: Name, ...[params]: OptionalParamsTuple<Name>): string
OptionalParamsTupleは後述
ルート名とそれに対応するパラメーターオブジェクトを渡すと、それらを使って作ったパス文字列を返す
パスを直書きしないようにするための関数
OptionalParamsTuple<Name>
何をしたいかというと、第2引数を条件的に(パラメーターオブジェクトが空のとき)省略可能にしたい
ルート名Nameに対応するパラメーターオブジェクトをPとすると、Pが空ではないときは[P]を、空のときは[P?]となる
もろConditional Types案件では...!!
matchedRoutesを実装する
code:ts
// マッチしたルートの情報を表す型
interface MatchedRoute<Name extends string, P> {
name: Name;
params: P;
}
// プレースホルダーの型を表す文字列から実際の型に変換するテーブル
interface PHValueTypesToRealTypes {
string: string
number: number
}
// Placeholder(またはそのunion)型から、パラメーターオブジェクトの型に変換する
type PlaceholdersToParams<Ph extends Placeholder> = {
[K in Ph'name']: PHValueTypesToRealTypes[Extract<Ph, {name: K}>'type'] };
// !!! RouteTypeのunion typesのそれぞれの要素をMatchedRouteに変換する !!!
type MatchedRoutes<T> = T extends RouteType<infer Name, infer Phs>
? MatchedRoute<Name, PlaceholdersToParams<Phsnumber>> : never;
type AllMatchedRoutes = MatchedRoutes<RouteTypes>;
type AllMatchedRoutesOrNull = AllMatchedRoutes | null;
function matchRoute(path: string): AllMatchedRoutesOrNull {
for (const {name, pattern, placeholders} of routes) {
const m = path.match(pattern);
if (m !== null) {
const params: any = {};
// ... mとplaceholdersからparamsを組み立てる
return {name, params} as AllMatchedRoutes;
}
}
// いずれのルートにもマッチしない場合はnullを返す
return null;
}
先ほどの例だと...
code:ts
matchRoute('/tweets/odiak_/83928908771')
// => {name: 'tweetDetail', params: {screenName: 'odiak_', tweetId: 83928908771}}
matchRoute('/favorites')
// => {name: 'favorites', params: {}}
matchRoute('/foo')
// => null
型のほうはどうでしょう
code:ts
const m = matchRoute(location.pathname)
if (m.name === 'tweetDetail') {
const {screenName, tweetId} = m.params
screenName // :=> string
tweetId // :=> number
}
union typesになっているので、型ガードが効く!
urlForRouteを実装する
code:ts
// パラメーターオブジェクトの型
type ParamsForName<Name extends RouteNames> = RoutesByNameName'params'; // keyf {}がneverになることを利用!
type OptionalParamsTuple<P> = keyof P extends never ? P? : P; export function urlForRoute<Name extends RouteNames>(
name: Name,
...params: OptionalParamsTuple<ParamsForName<Name>> ): string {
const normalizedParams: any = params || {};
const {strings, placeholders} = routesByNamename; for (const i, ph of placeholders.entries()) { path += String(normalizedParamsph.name); }
return path;
}
先ほどの例で動作確認
code:ts
urlForRoute('userDetail', {screenName: 'odiak_'})
// => '/users/odiak_'
urlForRoute('userDetail', {})
// type error!
urlForRoute('favorites')
// => '/favorites'
ルーターできた! 型のおかげでルートのパターンを網羅し忘れる心配もないしパラメーターも型に守られている!
こんなに大変なことやるほどのことか?などの突っ込みは受け付けます
でも意外と良い気もするので、ライブラリにするかも
終わりに
JavaScript自体が柔軟な言語ですが、TypeScriptはその柔らかい言語に上手に型を乗せていて、良くできているなぁといつも思います
今日の話を聴いて面白いなと思った方は、TypeScriptの型について調べてみて、型で遊んでみてください!
一緒に型の世界を楽しみましょう!
(@uhyo_ さんはTSに関する面白い情報を良く発信されているのでフォロー推奨です) 参考文献