👊

自分で型を書く力をつけるTypeScript実践ガイド

2025/03/01に公開

そろそろ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]のように書くものです。この記法によって、SomeTypeOtherTypeを継承したもの(代入可能)であれば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種類です。
分布によってパラメータが異なり、正規分布では平均値\muと標準偏差\sigma[16]、一様分布では区間[a, b][17]、指数分布では\lambda[18]をそれぞれ指定することで確率密度関数が一意に定まります。パラメータの数も違えば意味も大きく異なるため、これらは区別して使いたいです。

さて、これに対して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]を活用して実装することができます。RandTypeRandTypesを満足する型で、今回は3種類の分布のどれか1つを指します。

第一引数のrandTypeによってある分布RandTypeを特定し、これをGeneratorParamsへの呼び出しに使うと、その分布のパラメータをparamsに要求するようになります。実際にplaygroudで試してみたものが以下の図になります。これで呼び出し側に型情報が適切に提供されるようになりました。


例えばnormalを指定するとパラメータに\mu\sigmaを要求します

一方、genRandomNum()の中ではifswitchを使って型の絞り込み(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を拡張したCatDogの2つを用意して、簡単のために鳴き声で区別しました。現に鳴き声cryを文字列リテラル型で指定しています。
ユーザ定義型ガードisCatは、もちろんプロパティの存在を条件にすることもできますし、今回のように鳴き声が型と結びついている場合には鳴き声を条件にすることもできます。

以下に紹介するコードでは、未知の動物リストunknownAnimalListから猫を区別して抽出し、その後に猫に固有のプロパティにアクセスしています。
cat.whiskersLengthの部分ではcatCatの構造を持つと判断されているため、エラーは出ません。

// 動物の型を定義
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';
 }

最後に

今回紹介した内容を自由に扱えるだけで、幅広い型を自在に操れるようになると思います。
しかし、逆に自在に扱えるからといって既存のユーティリティ型を活用しなかったり、複雑怪奇な型を書いてしまっては本末転倒なので、適切に実務に活かせるようにしたいものです。

型はあくまでコードをより安全に、分かりやすくするための道具です。今回の内容が、ただ高度な型を書くこと自体を目的とせず、日々の実装を快適にする一助になれば幸いです。

脚注
  1. https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types ↩︎ ↩︎

  2. https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types ↩︎

  3. https://www.typescriptlang.org/docs/handbook/utility-types.html ↩︎

  4. https://typescriptbook.jp/reference/values-types-variables/satisfies ↩︎

  5. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator ↩︎

  6. https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-never-type ↩︎

  7. https://www.typescriptlang.org/tsconfig/#noUnusedLocals ↩︎

  8. https://www.typescriptlang.org/docs/handbook/2/conditional-types.html ↩︎ ↩︎

  9. https://www.typescriptlang.org/docs/handbook/2/mapped-types.html ↩︎

  10. https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as ↩︎

  11. https://typescriptbook.jp/reference/object-oriented/class/readonly-modifier-in-classes ↩︎

  12. https://developer.mozilla.org/ja/docs/Glossary/Mutable ↩︎

  13. https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#optional-properties ↩︎

  14. https://www.typescriptlang.org/docs/handbook/utility-types.html#returntypetype ↩︎

  15. https://dev.to/busypeoples/notes-on-typescript-returntype-3m5a ↩︎

  16. https://bellcurve.jp/statistics/course/7797.html? ↩︎

  17. https://bellcurve.jp/statistics/course/8013.html ↩︎

  18. https://bellcurve.jp/statistics/course/8009.html ↩︎

  19. https://www.typescriptlang.org/docs/handbook/2/generics.html#working-with-generic-type-variables ↩︎

  20. https://www.typescriptlang.org/docs/handbook/2/narrowing.html ↩︎

  21. https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates ↩︎

Discussion