自分で型を書く力をつけるTypeScript実践ガイド
そろそろTypeScriptの型をまじめに勉強しなきゃな...ということで、自分の勉強も兼ねて「一歩先の型活用」をまとめてみました。そのためユニオン[1]、インターセクション[2]、ユーティリティ型[3]といった基本的な部分は触れていません。
この投稿では:
- できる限りTypeScript特有の用語には脚注でハンドブックのリンクを示しています
- 実際のコードを示すことで「実際に動かして学べるように」しています
- 可能な限り極端な例を出して分かりやすくなるようにしています
- ここで示すコードが実務には適さない場合もあります
- 順序立ててなるべく分かりやすいように説明しています(自分も忘れてしまうので)
この投稿を通じてワンランク上の型スキル獲得に結びつけば幸いです。
分岐の網羅性を保証する
値のパターンによって処理を分岐したいとき、そのパターンがユニオン(Union Types)[1:1]によって全てが型レベルで明らかになっている場合には、ここで紹介するsatisfies never
を使うことで網羅性を保証することができます[4][5]。
それまでに処理がブロックを離脱して、非到達性(never[6])を確認するためのステートメントです。
type Status = 'success' | 'failed' | 'pending';
/** 文字列のステータスをHTTPステータスコードに変換する */
const convertStatusCode = (status: Status) => {
if (status === 'success') return 200;
if (status === 'failed') return 401;
if (status === 'pending') return 100;
// 選択肢の網羅性を保証
status satisfies never;
// ランタイムでもエラーを出す
throw new Error(`unknown status! > ${status}`);
};
網羅性を検証する部分はconst exhaustiveCheck = status satisfies never;
としても良いのですが、noUnusedLocals
[7]が制約に含まれると使用されないローカル変数の宣言がエラーとなってしまうため、今回はこのようにstatus satisfies never;
としています。
Conditional Typesで型の条件分岐を行う
次にconditional types(型の条件分岐)を活用したいくつかの例を紹介しようと思います。
そもそもconditional typesとは、三項演算子と同じ文法を使ってSomeType extends OtherType ? TrueType : FalseType;
[8]のように書くものです。この記法によって、SomeType
がOtherType
を継承したもの(代入可能)であればTrueType
、でなければFalseType
を採用します。
これを活用すると、以下のように型変数T
によって柔軟に推論させることができます。公式ハンドブック[8:1]のコードから少し改変しています。
// ラベルに関する型を2つ用意する
type NameLabel = {
name: string;
};
type IdLabel = {
id: number;
};
// stringとnumberを受け取った場合で、それぞれ型を変化させる
type IdOrName<T extends string | number> = T extends number ? IdLabel : NameLabel;
// 受け取った値の型に応じてラベルを作成する関数 (未実装)
const makeLabel = <T extends string | number>(label: T): IdOrName<T> => {
throw '実装は省略';
};
const stringLabel = makeLabel('kona'); // 返り値は`NameLabel`と推論される
const numberLabel = makeLabel(57); // 返り値は`IdLabel`と推論される
このような型に関する考え方を、この先発展させて紹介します。
inを使った動的なプロパティの絞り込み
先ほどのconditional typesをキー側に適用してみた例が以下に示すコードです。このコードではオブジェクトのプロパティのうち、値がstring
であるものだけを残した新しい型を作成しています。
// 値がstringのプロパティのみを残す型
type OnlyStringKeys<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
// ユーザ情報を格納する型
type User = {
id: number;
name: string;
description: string;
isActive: boolean;
auth: string[];
};
type StringOnlyUser = OnlyStringKeys<User>;
// 値がstringのプロパティだけが残る
// { name: string; description: string; }
OnlyStringKeys
の定義は初見ではなかなか難解な書き方になっているので、順序立てて説明していきます。
ここではmapped types[9]を活用しています。これを使うと、以下のように各プロパティについてイテレーションを導入したかのように書くことができます。
この場合では、keyof T
とすると「与えられたオブジェクトのキー名全て」となり、それをK
で一つずつ扱っていくことで、つまり「K: T[K]
(K
は全てのキー)」という処理になります。
// 元の型Tのまま
type MappedType<T> = {
[K in keyof T]: T[K];
};
この書き方にas
を使ったkey remapping[10]を導入すると、キーを上書きすることができるようになります。このas
の部分にconditional typesを導入したものが先ほどのコードになります。T[K]
がstring
であればK
をそのまま、でなければnever
を返します。
キーにnever
型をとると、そのキーはオブジェクトから除外されます。結果として、値がstring
であるもの以外は除かれるわけです。
以下に試しに全てのkeyをnever
にしたkey remappingを試してみた結果を紹介します。
// 空のobjectになる
type NeverKeys<T> = {
[K in keyof T as never]: T[K];
};
type Empty = NeverKeys<User>;
Empty
は空のobjectとなります
readonlyを解除したり追加する
先ほどのmapped typesを活用して、readonly
[11]を追加・削除する型を紹介します。相互にミュータブル・イミュータブル[12]を変換し、意図した通りのreadonly
の状態となっていることを確認できます。
readonly
の制約を削除する場合には、明示的に-readonly
と書く必要があります。この記法を怠ると、そのままreadonly
の状態が継承されてしまいますので注意が必要です。
// プロパティのreadonlyを解除する型
type Mutable<T> = {
-readonly [K in keyof T]: T[K] extends object ? Mutable<T[K]> : T[K];
};
// プロパティをreadonlyにする型
type Immutable<T> = {
readonly [K in keyof T]: T[K] extends object ? Immutable<T[K]> : T[K];
};
// 元の型
type Original = {
readonly a: number;
readonly b: {
readonly c: number;
};
};
// ミュータブル・イミュータブルにした型を定義
type MutableObj = Mutable<Original>;
type ImmutableObj = Immutable<MutableObj>; // これはOriginalと等価
const mutable: MutableObj = {
a: 0,
b: { c: 0 },
};
mutable.a = 10; // 代入できる
const original: ImmutableObj = {
a: 0,
b: { c: 0 },
};
original.a = 10; // 代入できない
inferを使って推論結果を活用する
infer
は「推論」という意味の英単語ですが、このキーワードを使うことでTypeScriptの型推論の結果を活用して型を定義することができます。
このinfer
はconditional typesのT extends U ? X : Y
の中でのみ使うことができます。
あまり単体で使うことはないですが、例えばユーティリティ型のReturnType
[14]の実装にはこのinfer
が使われており[15]、以下にそのイメージを示しています。
(...args: any) => any
は通常の関数の形です。任意個の引数をとり、値を返します(any
なので配列でもオブジェクトでも構わない)。T
が関数である場合にR
を、そうでなければany
を返すようになっています。
そのため、この例でもstring
といったプリミティブ型などを渡してみると、any
と推論されます。
// ユーティリティ型ReturnTypeの実装イメージ
type ReturnTypeCustom<T> = T extends (...args: any) => infer R ? R : any;
// 関数以外を渡すとNotFunctionはany型と推論される
type NotFunction = ReturnTypeCustom<string[]>;
// 関数を渡すとFunctionTypeは渡した関数の返り値の型string[]と推論される
type FunctionType = ReturnTypeCustom<() => string[]>;
もう少しinfer
の使いどころを深堀してみましょう。以下の例では、Promise
が包んでいる型と、配列の各要素の型を推論する型を定義しています。
=
の右辺にある定義部分では、extends
句の右辺で新しい型変数を宣言することはできません。今までは左辺にあるジェネリクスなどを活用していましたが、今回のような場合では右辺に新たに型変数を置く必要があります。
そこで必要なのがinfer
となるわけです。
例えば以下の最初の例ではPromise
の中身R
を新たに型変数を置くためにinfer
を使っています。これを使えないと、ジェネリクスの中で宣言する必要があるため、type UnwrapPromise<T, R> = T extends Promise<R> ? R : T;
というような形になりますが、これは上手く機能しません。
// Promiseであれば中身の型を、でなければそのまま返す
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
// stringと推論される
type UnwrappedStringPromise = UnwrapPromise<Promise<string>>;
// 配列であれば要素の型を、でなければ`never`を返す
type UnwrapArray<T> = T extends (infer R)[] ? R : never;
// stringと推論される
type UnwrappedStringArray = UnwrapArray<string[]>;
// 多次元配列でも要素の型を取り出せるようにする
type DeepUnwrapArray<T> = T extends (infer R)[] ? DeepUnwrapArray<R> : T;
// stringと推論される
type CompletelyUnwrapped = DeepUnwrapArray<string[][][]>;
関数の引数を動的に変化させる
例えば、いくつかの種類の分布に従う乱数を1つ生成できる関数を実装する場合を考えてみます。
GeneratorParams
には実装したい乱数の種類とパラメータを定義してまとめています。今回用意したのは3種類です。
分布によってパラメータが異なり、正規分布では平均値
さて、これに対してkeyof GeneratorParams
とすることでキー名のユニオンを取得でき、これは今回実装したい分布のどれか一つを指し示す文字列リテラルとなります。
これらを活用して第一引数に分布の種類、第二引数にパラメータを指定すると分布に従う1つの疑似乱数を返す関数genRandomNum()
を実装してみました。コアとして別途generators
というオブジェクトを定義しています。
type GeneratorParams = {
/** 正規分布 */
normal: { mu: number; sigma: number };
/** 一様分布 */
uniform: { a: number; b: number };
/** 指数分布 */
exponential: { lambda: number };
};
/** 生成可能な乱数の分布 */
type RandTypes = keyof GeneratorParams;
/** 乱数生成のジェネレータのコア部分 */
const generators: {
[K in RandTypes]: (params: GeneratorParams[K]) => number;
} = {
normal: ({ mu, sigma }) => boxMuller(mu, sigma),
uniform: ({ a, b }) => a + (b - a) * Math.random(),
exponential: ({ lambda }) => expRand(lambda),
};
// 実際に使う際はこの関数を経由して呼び出す
const genRandomNum = <RandType extends RandTypes>(randType: RandType, params: GeneratorParams[RandType]): number => {
const generator = generators[randType];
return generator(params);
};
乱数生成のコアの部分には実装が省かれているものもありますが、このように型変数[19]を活用して実装することができます。RandType
はRandTypes
を満足する型で、今回は3種類の分布のどれか1つを指します。
第一引数のrandType
によってある分布RandType
を特定し、これをGeneratorParams
への呼び出しに使うと、その分布のパラメータをparams
に要求するようになります。実際にplaygroudで試してみたものが以下の図になります。これで呼び出し側に型情報が適切に提供されるようになりました。
例えばnormal
を指定するとパラメータに
一方、genRandomNum()
の中ではif
やswitch
を使って型の絞り込み(narrowing)[20]をしても、params
の型は適切に絞り込むほどの型推論が機能しません。実際に以下のように書くだけでは各分布のパラメータのユニオンとなってしまうため、さらなる型の絞り込みが必要です。
/** 指定された分布に従う乱数を1つ生成する */
const genRandomNum = <RandType extends RandTypes>(randType: RandType, params: GeneratorParams[RandType]): number => {
if (randType === 'normal') {
/** paramsは各分布のパラメータのユニオンとなり、型エラーとなる */
const { mu, sigma } = params;
return boxMuller(mu, sigma);
}
if (randType === 'uniform') {
const { a, b } = params;
return a + (b - a) * Math.random();
}
if (randType === 'exponential') {
const { lambda } = params;
return expRand(lambda);
}
randType satisfies never;
// ランタイム側でエラーにする
throw new Error(`unknown randType! > ${randType}`);
}
Property 'mu' does not exist on type '{ mu: number; sigma: number; } | { a: number; b: number; } | { lambda: number; }'.
そのため今回は、実装側(genRandomNum()
)の型の補助をさらに追加するためにgenerators
というオブジェクトを定義し、ここで乱数を生成する関数を集約しています。この部分の型情報により、分布の種類を追加/削除した際にはトランスパイル時にエラーが発生するようになり、より開発時のミスを減らせる実装とすることができます。
ユーザ定義の型ガード
上記の例のように型推論が適切になされないとき、ユーザ定義の型ガード[21]を導入することで型推論の結果を強制することができます。
以下では別のコードの例を示していますが、もちろん応用可能です。
今回はAnimal
を拡張したCat
とDog
の2つを用意して、簡単のために鳴き声で区別しました。現に鳴き声cry
を文字列リテラル型で指定しています。
ユーザ定義型ガードisCat
は、もちろんプロパティの存在を条件にすることもできますし、今回のように鳴き声が型と結びついている場合には鳴き声を条件にすることもできます。
以下に紹介するコードでは、未知の動物リストunknownAnimalList
から猫を区別して抽出し、その後に猫に固有のプロパティにアクセスしています。
cat.whiskersLength
の部分ではcat
はCat
の構造を持つと判断されているため、エラーは出ません。
// 動物の型を定義
type Animal = {
id: number;
name: string;
cry: string;
};
// ヒゲと尻尾の長さのプロパティで猫と犬を区別する
type Cat = Animal & {
cry: 'meow';
whiskersLength: number;
};
type Dog = Animal & {
cry: 'bowwow';
tailLength: number;
};
// 猫の型を区別するユーザ定義の型ガード
function isCat(obj: any): obj is Cat {
return obj?.cry === 'meow';
}
// 未知の動物リスト
const unknownAnimalList: unknown[] = [
{ name: 'kona', cry: 'meow', whiskersLength: 5 },
{ name: 'papi', cry: 'bowwow', tailLength: 20 },
];
// 猫を区別する
const cat = unknownAnimalList.find((v) => isCat(v));
// const cat = unknownAnimalList.find(isCat);
if (cat) {
console.log(JSON.stringify(cat, null, 2));
console.log(cat.whiskersLength);
}
上記の例ではfind
を使っていますが、そもそもisCat
の実装はboolean
で判定しているただの関数で、通常のif
文分岐としても使うことができるため、応用の幅が広いです。
ユーザ定義の型ガードでは、定義の仕方によって判定の厳格さも変わり、判定する方法を間違えると誤った型情報が付与されてしまうため、実際に運用する際には注意する必要があります。
例えば、以下の例では不適切な条件により型を判定するため、Cat
と判定されたのにもかかわらずcat.whiskersLength
にアクセスするとundefined
となってしまいます。
あくまでも型ガードが正しいと静的解析されるため、ランタイムレベルでしか見抜けなくなってしまう点には要注意です。
// 猫の型を区別するユーザ定義の型ガード
function isCat(obj: any): obj is Cat {
+ return obj?.cry === 'bowwow'; // 条件を誤っている
- return obj?.cry === 'meow';
}
最後に
今回紹介した内容を自由に扱えるだけで、幅広い型を自在に操れるようになると思います。
しかし、逆に自在に扱えるからといって既存のユーティリティ型を活用しなかったり、複雑怪奇な型を書いてしまっては本末転倒なので、適切に実務に活かせるようにしたいものです。
型はあくまでコードをより安全に、分かりやすくするための道具です。今回の内容が、ただ高度な型を書くこと自体を目的とせず、日々の実装を快適にする一助になれば幸いです。
-
https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types ↩︎ ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types ↩︎
-
https://www.typescriptlang.org/docs/handbook/utility-types.html ↩︎
-
https://typescriptbook.jp/reference/values-types-variables/satisfies ↩︎
-
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-never-type ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/conditional-types.html ↩︎ ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/mapped-types.html ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as ↩︎
-
https://typescriptbook.jp/reference/object-oriented/class/readonly-modifier-in-classes ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#optional-properties ↩︎
-
https://www.typescriptlang.org/docs/handbook/utility-types.html#returntypetype ↩︎
-
https://dev.to/busypeoples/notes-on-typescript-returntype-3m5a ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/generics.html#working-with-generic-type-variables ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/narrowing.html ↩︎
-
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates ↩︎
Discussion