11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

TypeScriptで文字列からキーを抽出し、オブジェクトの型に変換する方法

Last updated at Posted at 2022-08-01

はいさい!ちゅらデータぬオースティンやいびーん!

概要

TypeScriptで文字列からキーを抽出し、オブジェクトの型(type)に変換する方法を紹介します。

背景

Express.jsなどのフレームワークで以下のようなコードをよく書きます。

app.get("/:id", (req, res) => res.send(req.params.id));

読者に気づいた方はいらっしゃると思いますが、このreq.params.idは、TypeScriptで書いていると、req.paramsの型に含まれています。

つまり、req.paramsは以下のような型になっています。

type Params = { id: string } & { [name: string]: string};

req.params.idをVS Codeで呼ぼうとすると、intellisenseで推測してくれるのではないでしょうか?

"/:id"と書いただけで、なぜTypeScriptは推測できるようになっているのだろうか気になりませんか?

どうなっているのか、以下解説します。

TypeScriptの技術

上記の推測を実現するためにはTypeScriptの4つの機能が使われています。

Generic Types (ジェネリック型)

TypeScriptをよくご存知の方ならば、一度は使って遊んだことがあるかと思います。

例えば、以下のようにdoNothingという、引数を返すだけの関数を定義するとしましょう。

引数の型に合わせて返す値の型を設定したいです。

  1. 文字列を引数に渡したら、文字列を返す
  2. 数値を引数に渡したら、数値を返す

すると、Generic Typesを使うと、上記のような型を書くことができます。

function doNothing<T>(value: T): T {
  return value
}

const doNothingArrow = <T>(value: T) => value; // Arrow関数の場合はこのような構文

これはよくFunction Overloadsで使用されますが、今回は下記の技術と一緒に使います。

Template Literal Types (テンプレート・リテラル型)

実は、TypeScipt v4.1から、文字列から型を抽出する機能が入っていたのです。

上記の正式ドキュメントの説明を借りますと、

type World = "world";
 
type Greeting = `hello ${World}`; // Greeting: "hello world"

このように、型の定義に違う型をはめられることが後々役立ってきます。

Conditional Types (条件的型)

次のTypeScript技術は、条件によって性質が変わる型の定義です。

上記の正式ドキュメントの例を拝借しますと、以下のような型を書くことができます。

type TypeName<T> = T extends string ? string : unknown;
type T0 = TypeName<string>; // type T0 = string
type T1 = TypeName<"a">; // type T1 = string
type T2 = TypeName<number> // type T2 = unknown
type T3 = TypeName<1> // type T3 = unknown

TというGeneric Typeを定義し、それがもしstringという型から成っていれば、stringとして認めるが、それ以外だと、わからないという意味でunknownにします。

すると、文字列をGeneric Typeの引数に渡してT1という型を定義すると、その型定義の結果がstringになります。それ以外の値だと、unknownになります。

この機能もまた、後ほど役立ちますので、脇に置いておきましょう。

条件的型におけるType inference (型推測)

最後に必要なTypeScript機能は、条件的型の定義に使える、型を推測する機能です。

正式ドキュメントの例ですが、以下のようにオブジェクトの中に入っているabのキーの値の型を推測し、型として定義しています。

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;
type T10 = Foo<{ a: string; b: string }>; // string
type T11 = Foo<{ a: string; b: number }>; // string | number

型から、型を抽出する、これを覚えていただければと思います。

ExpressのParam推測を自作してみる

これからは、上記でご紹介したTypeScript機能を使い、Expressのパラメーターの型を推測する機能を再現してみます。

:が入っている文字列から型を抽出する

まず、Expressの場合、:が入っている文字列のみ、パラメーターとして認定しているので、:が入っているかどうかをチェックする条件的型を定義します。

type GetTypeIfHasColon<T> = T extends `:${infer U}` ? U : never;

type T0 = GetTypeIfHasColon<":aa">; // "aa"
type T1 = GetTypeIfHasColon<"aa">; // never

ここで、テンプレート・リテラルで、正規表現のようなチェックをしています。正規表現だと、/:+/のような書き方になるかと思います。

その正規表現チェックが通れば、そのセミコロンを省いた文字列の型を推測し、返す。でなければ、neverを返す。

ちなみに、neverは、あり得ない処理、あり得ない結果の時に使われる型。この場合は、neverを使って、TypeScriptに、セミコロンのパターンに一致しなかったら、無視していいと指定しています。

/が入っている文字列から型を抽出する

次に解決しないといけない問題は、Expressのパスに入っているバックスラッシュ/をどうするかです。

まず、上記と同じように、テンプレート・リテラルで型を推測するようにしましょう。

type SplitPath<T> = T extends `${infer U}/${infer V}` ? U | V : T;

type T2 = SplitPath<"/:aa"> // "" | ":aa"
type T3 = SplitPath<":aa"> // ":aa"
type T4 = SplitPath<"/:aa/data"> // "" | ":aa/data"

T4の結果が、望ましくないですね。ここで必要なのは、再起的な処理です。

type SplitPathRecursive<T> = T extends `${infer U}/${infer V}` ? U | SplitPathRecursive<V> : T;

type T2 = SplitPathRecursive<"/:aa"> // "" | ":aa"
type T3 = SplitPathRecursive<":aa"> // ":aa"
type T4 = SplitPathRecursive<"/:aa/data"> // "" | "data" | ":aa"

こうすると/がたくさんあっても検知できるようになりました!

:/のテンプレート・リテラル型を合わせる

最後に、上記のSplitPathRecursiveGetTypeIfHasColonを組み込みます。

type SplitPathRecursive<T> = T extends `${infer U}/${infer V}`
  ? GetTypeIfHasColon<U> | SplitPathRecursive<V>
  : GetTypeIfHasColon<T>;

type T2 = SplitPathRecursive<"/:aa">; // "aa"
type T3 = SplitPathRecursive<":aa">; // "aa"
type T4 = SplitPathRecursive<"/:aa/data">; // "aa"

これで、Expressらしくなってきましたね!ただ、Expressだと、req.paramsのように、オブジェクトになっていますので、もう少し頑張りましょう。

Recordの効用型を使う

文字列の型"a"を、オブジェクトのキーになる型を作ることができます。Recordという、TypeScriptに内蔵されているUtility Type(効用型)を使います。

type MyRecord = Record<"a", string> // { a: string }

これを上記のSplitPathRecursiveと併用すると、パラメーターのキーにすることができます。値の型が必ずstringになることもExpressの仕組み上わかっているので以下のようなコードを書きます。

type ExpressInferredParams<T> = Record<SplitPathRecursive<T>, string>;

type T5 = ExpressInferredParams<"/:aa/data">; // { aa: string; }
const params: T5 = { aa: "Hello!" };

もう終わりが見えてきましたね。

パラメーターで指定していないキーでも使えるようにする

Expressだと、POSTリクエストのパラメーターはパスだけでないので、上記の定義だと、TypeScriptインタープリターが知らないパラメーターに対してエラーを吐き出すようになります。

解決するためには、以下のような型とユニオンにすることです。

type ExpressInferredParams<T> = Record<SplitPathRecursive<T>, string>;
type ExpressParams<T> = ExpressInferredParams<T> & { [name: string]: string };

type T5 = ExpressParams<"/:aa/data">;
// ExpressInferredParams<"/:aa/data"> & {
//  [name: string]: string;
// }
const params: T5 = { aa: "1", picture: "abc" }

Expressと同じような結果、できましたね!

まとめ

以上、ここまで、TypeScriptの様々な機能を使って、文字列からオブジェクトのキーを型に変換する方法を紹介してきましたが、いかがでしょうか?

実際、Expressのソースコードを見ていないので、このような実装になっているのか分かりませんが、きっと筆者以上に鋭い解決方法を使っているのではないかと思います!

TypeScriptのこういった機能をよく理解して使えるようになりたいものですね。

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?