React のコーディングで Tagged Template Literals は、よく使われる様になりました。styled-components にはじまり、GraphQLや lit-html などのライブラリでも使われ、お馴染みになったこの構文。さて、この Tagged Template Literals を利用した便利な関数ですが、型推論がどの様に行われているのか、気になりませんか?
プレースホルダー関数型推論
例えば次の様な styled-components は、Function Component に適用されたProps
型を、プレースホルダー関数で引数型を勝手に推論してくれます。
type Props = { hoge: string }
const Component: React.FC<Props> = props => (
<div>hello world</div>
)
const StyledComponent = styled(Component)`
${props => props.hoge} // (property) hoge: string
`
これと同じ様に、自作タグ関数であっても、プレースホルダー関数で引数型が推論できる様にすることが、本日のお題です。
Tagged Template Literals をおさらいする
これは普通の Template Literals です。当然 string型として推論されます。
const s1 = `hoge${0}fuga` // const s1: string
// "hoge0fuga"
const s2 = `hoge${0}fuga${'1'}piyo${false}` // const s2: string
// "hoge0fuga1piyofalse"
タグ関数は、関数に続き Template Literals を記述することで、関数呼び出しとして実行されます。
tagged`hoge${0}fuga${'1'}piyo${false}`
// "hoge0fuga1piyofalse"
このタグ関数実装は次の様になっていました。第一引数は「文字列配列」以降の引数は「プレースホルダー配列」です。関数で受け取る第一引数は['hoge','fuga','piyo']
、以降の引数は[0, '1', false]
となります。
function tagged(strings, ...placeholders) {
return strings.reduce((a, b, i) => {
const p = placeholders[i - 1]
return a + p + b
})
}
tagged`hoge${0}fuga${'1'}piyo${false}`
// "hoge0fuga1piyofalse"
プレースホルダーの関数を実行する
styled-components の様に、プレースホルダーに関数を与え実行するためには、どの様にすれば良いでしょうか?次の出力が目指す結果です。tagged関数で与えられた2
が、それぞれのプレースホルダー関数引数n
に与えられます。
tagged(2)`hoge${n => n * 0}fuga${n => n * 1}piyo${n => n * 2}`
// "hoge0fuga2piyo4"
正解は次の様な実装です。HOFとして定義し、関数として評価できるプレースホルダーに対し、引数props
を実行時に与えます。
function tagged(props) {
return (strings, ...placeholders) => {
return strings.reduce((a, b, i) => {
const p = placeholders[i - 1]
if (typeof p === 'function') {
return a + p(props) + b
}
return a + p + b
})
}
}
さて、本題はここからです。このままでは当然、型推論は効きません。この関数に対し、適切な推論が導出される様に、型情報を付与していきます。
関数に注釈を付与する
簡略化のため飛ばしましたが、タグ関数に渡される第1引数では特別なrawプロパティが利用でき、エスケープシーケンスが処理されない、入力された通りの生の文字列を参照できます。これを表現するための型が typescript から標準で提供されています。import をする必要はありません。
interface TemplateStringsArray extends ReadonlyArray<string> {
readonly raw: ReadonlyArray<string>;
}
先に答えを言ってしまうと次の型定義で、引数n に推論が効く様になります。tagged(2)
としているところを、tagged('2')
の様に変更すると、無事コンパイルエラーを得ることが出来ます。
type Placeholder<T> = (...props: T[]) => unknown
function tagged<T>(props: T) {
return (strings: TemplateStringsArray, ...placeholders: Placeholder<T>[]) => {
return strings.reduce((a, b, i) => {
const p = placeholders[i - 1]
if (typeof p === 'function') {
return a + p(props) + b
}
return a + p + b
})
}
}
const s = tagged(2)`hoge${n => n * 0}fuga${n => n * 1}piyo${n => n * 2}`
// (parameter) n: number
ただしこのままでは、プレースホルダーを全て関数とする必要があります。そのため次の様に、PrimitivePlaceholder
型とFunctionPlaceholder
型をそれぞれ用意し、UnionTypes とすることでこの問題を解決することが出来ました。ついでに null | undefined
のガードも実装に追加しました。
type PrimitivePlaceholder = number | string | boolean | null | undefined
type FunctionPlaceholder<T> = (...props: T[]) => unknown
type Placeholder<T> = FunctionPlaceholder<T> | PrimitivePlaceholder
function tagged<T>(props: T) {
return (strings: TemplateStringsArray, ...placeholders: Placeholder<T>[]) => {
return strings.reduce((a, b, i) => {
const p = placeholders[i - 1]
if (typeof p === 'function') {
return a + p(props) + b
}
if (p === null || p === undefined) {
return a + b
}
return a + p + b
})
}
}
const s = tagged(2)`hoge${null}fuga${'1'}piyo${n => n * 2}`
// "hogefuga1piyo4"
おわりに
実際に DefinitelyTyped から提供されている style-components の型は、これよりももっと複雑です。また、Tagged Template Literals はネストすることが可能なので、再帰的な型定義も必要になってきます。
Tagged Template Literals を利用したライブラリ推論の、基本的なベースはこのとおりなはずなので、読み解く際のヒントになれば幸いです。
参考文献:https://basarat.gitbooks.io/typescript/docs/template-strings.html