TypeScript でライブラリを作成していると、『基本的にはどんな文字列でも受け入れるけど、特定の文字列だと挙動が変わる』 ような引数がある関数を作成したい時があります。
この場合、引数の値をユーザーが入力しようとした時に、受け入れる特定の値を IDE がサジェストしてくれると嬉しいでしょう。
例えば、 React で <a>
タグの target
属性を指定できるような関数コンポーネントを作成する場合、target
属性が取りうる特殊な値 _blank
_parent
_self
_top
がサジェストされれば、コンポーネントを使っている側は嬉しいはずです。
もしこれを素直に書いてみると、以下のようになります。
type LinkProps = {
href: string;
target?: "_blank" | "_parent" | "_self" | "_top" | string;
};
const Link: React.FC<LinkProps> = ({ children, href, target }) => (
<a
href={href}
target={target}
rel={target === "_blank" ? "noopener noreferrer" : "noopener"}
>
{children}
</a>
);
しかし、型を定義してみるとわかりますが、文字列リテラルが全部 string
型に飲まれてしまいます。
文字列リテラルの情報が型から失われてしまうので、実際に使ってみても当然ながらサジェストしてくれません。
もちろん string
型を外せばサジェストされますが、 target
属性は実際に任意の文字列(ウィンドウ名)を取りうるので、外すわけにもいきません…。
『サジェスト』と『任意の文字列の受け入れ』を同時に機能させるには、どうすればよいでしょう?
TL;DR
-
string
と区別した型を作って、文字列リテラルが飲まれないようにする
(依存関係を増やしたくない人向け) -
型コレクションライブラリ
type-fest
のLiteralUnion
を使う
(面倒なことを考えたくない人向け)
前者の方法は、前提知識が無ければ「なぜこんなことしてるの?」と首を傾げたくなるような記述になるので、普通は後者の type-fest
を使えば充分でしょう。
以下、それぞれの方法について解説していきます。
string
と区別した型を作って、文字列リテラルが飲まれないようにする
文字列リテラルは string
の部分型扱いなので、 string
型に飲まれるのを回避することができれば、サジェストが機能する ようになります。
例えば、プリミティブ型の string
の代わりに、ノンプリミティブな String
を使うと、リテラルが飲み込まれず、サジェストを効かせながら普通の文字列も受け入れることができます。しかし、この 2 つは元々別モノで互換性がなく、TypeScript 公式でも『String
は使うな』と言われています ので、賢い方法とは言えません。
type LinkProps = {
href: string;
target?: "_blank" | "_parent" | "_self" | "_top" | String; // 👈 使っちゃダメ ❌
};
const Link: React.FC<LinkProps> = ({ children, href, target }) => (
<a
href={href}
target={target} // 👈 ❌ Type 'String' is not assignable to type 'string'.
rel={target === "_blank" ? "noopener noreferrer" : undefined}
>
{children}
</a>
);
より良いアプローチのためには、 『string
プリミティブ型と互換性があるが、 string
とは区別された型を定義して使う』 必要があります。
公称型
区別された型の作り方として、TypeScript で公称型を実現するテクニックに触れておきましょう。
『公称型』 とは、Java などで採用される、型の継承関係をベースとした型付けの方法です。仮に 2 つの型が全く同じ構造を持っていても、それぞれが別個に定義されていて、同じ親が継承関係にない場合は、違う型として扱われます。
一方、TypeScript が採用している 『構造的部分型』 では、全く同じ構造の型でさえあれば、互換性がある型として扱われます。
逆に言えば、微妙に型の構造が違えば、公称型と同じように違う型として扱われるので、 型の構造を少しずらす ことで、string
と区別する型を作ることができます。TypeScript において、型安全性をより高めるテクニックの 1 つです。
declare const nominalString: unique symbol;
type NominalString = string & { [nominalString]: never };
const a: NominalString = "foobar"; // 👈 公称型なので string とは区別され、代入できない ❌
参考記事
公称型の作り方や活用については、以下の記事が詳しいのでここでは割愛。
-
公称型を TypeScript で実現するための基礎
(https://qiita.com/suin/items/ae9ed911ebab48c98835) -
TypeScript: 「電話番号型」という意味を持たせた特殊なstring型の作り方
(https://qiita.com/suin/items/421ae4bf99d333ccfe0d)
互換性のある型を作る
上記の公称型を作るテクニックと、TypeScript の構造的部分型の特性をうまくミックスすると、 『型は区別されるが、元の型と互換性のある型』 を作ることができます。
declare const compatibleString: unique symbol;
type CompatibleString = string & { [compatibleString]?: never };
const a: CompatibleString = "foobar"; // 👈 代入できる ⭕️
ずらした型の定義が [compatibleString]?
と、 optional (省略可能) になっている ことに注目してください。
string
型では [compatibleString]
は常に undefined
です。そのため、『型構造に互換性がある』ということで、 CompatibleString
型を string
型へ代入することもできます。
// `string` は常に `[compatibleString]: undefined` なので、逆も OK
const b: string = "foobar" as CompatibleString; // 👈 ⭕️
つまり、CompatibleString
型は、string
型と区別なく扱えるのです。
文字列リテラルとの共用体型を提供する
では、作った型を文字列リテラルと組み合わせてみましょう。
declare const targetWindowName: unique symbol;
// 新たに作る型は、目的に沿った名前にするとベター
type TargetWindowName = string & { [targetWindowName]?: never };
type LinkProps = {
href: string;
target?: "_blank" | "_parent" | "_self" | "_top" | TargetWindowName;
};
型の構造が string
とは異なるので、文字列リテラルが飲み込まれずに済みます。
これで、任意の文字列を受け入れつつ、IDE で特定の文字列リテラルをサジェストできるようになりました!
ちなみに、もっとシンプルに string & {}
を使っても区別された状態になるようです (TS 3.8 で確認)。
型コレクションライブラリ type-fest
の LiteralUnion
を使う
上記に基づいて、自分で型を作っても良いですが、型コレクションライブラリ type-fest
が提供している LiteralUnion
を使うと、上記で説明したような処理をやってくれます。
import { LiteralUnion } from "type-fest";
type LinkProps = {
href: string;
target?: LiteralUnion<"_blank" | "_parent" | "_self" | "_top", string>;
};
「リテラルとの Union 型」という意図が明確になるので、個人的にはこっちを使うのがベターかな〜と思っています。
-
sindresorhus/type-fest: A collection of essential TypeScript types
(https://github.com/sindresorhus/type-fest)
おわりに
TypeScript で任意の文字列を受け入れつつ、文字列リテラルをサジェストしてくれる型を作る方法についてご紹介しました。TS は良い意味で沼にハマるとディープですね…。