LoginSignup
40
38

More than 3 years have passed since last update.

TypeScript で任意の文字列を受け入れつつ、文字列リテラルをサジェストしてくれる型を作る

Last updated at Posted at 2020-04-30

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 型に飲まれてしまいます。

スクリーンショット 2020-04-30 13.52.13.png

文字列リテラルの情報が型から失われてしまうので、実際に使ってみても当然ながらサジェストしてくれません。

スクリーンショット 2020-04-30 12.13.55.png

もちろん string 型を外せばサジェストされますが、 target 属性は実際に任意の文字列(ウィンドウ名)を取りうるので、外すわけにもいきません…。

『サジェスト』と『任意の文字列の受け入れ』を同時に機能させるには、どうすればよいでしょう?

TL;DR

前者の方法は、前提知識が無ければ「なぜこんなことしてるの?」と首を傾げたくなるような記述になるので、普通は後者の 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 の構造的部分型の特性をうまくミックスすると、 『型は区別されるが、元の型と互換性のある型』 を作ることができます。

互換性のある型を作る
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 とは異なるので、文字列リテラルが飲み込まれずに済みます。

スクリーンショット 2020-04-30 14.35.56.png

これで、任意の文字列を受け入れつつ、IDE で特定の文字列リテラルをサジェストできるようになりました!

スクリーンショット 2020-04-30 13.57.16.png

ちなみに、もっとシンプルに string & {} を使っても区別された状態になるようです (TS 3.8 で確認)。

型コレクションライブラリ type-festLiteralUnion を使う

上記に基づいて、自分で型を作っても良いですが、型コレクションライブラリ type-fest が提供している LiteralUnion を使うと、上記で説明したような処理をやってくれます。

type-festを使う
import { LiteralUnion } from "type-fest";

type LinkProps = {
  href: string;
  target?: LiteralUnion<"_blank" | "_parent" | "_self" | "_top", string>;
};

「リテラルとの Union 型」という意図が明確になるので、個人的にはこっちを使うのがベターかな〜と思っています。

おわりに

TypeScript で任意の文字列を受け入れつつ、文字列リテラルをサジェストしてくれる型を作る方法についてご紹介しました。TS は良い意味で沼にハマるとディープですね…。

参考文献

40
38
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
40
38