はじめに

JavaScript/TypeScript初学者を抜けたあたりの方に向けてTypeScriptの利点や限界、型レベルプログラミングについて紹介します。
TypeScriptはJavaScriptをラップしたライブラリであり、静的な型情報をつけることができます。また、それらの型情報をもとに型を推論し、型違反な代入やプロパティへのアクセスなどをプログラム実行 以前 に検知することができるようになっています。

例えば、動的型付けの言語であるJavaScriptでは、以下の関数のa,bには数値だけでなく、文字列を渡すことができます。数値同士を渡せば加算してくれますし、文字列同士を渡せば文字列連結をしてくれます。

const add = (a, b) => {
    return a + b;
};

「数値計算用の関数なのに、文字列連結に利用できてしまう」という緩さを開発時にはなるべく排除しようというのがTypeScriptを導入する意義です。

以下、TypeScriptの基本的な型の表現について紹介します。

基本的な型の表現

型推論

TypeScriptは極めて優秀な型推論機能がついており、特定の意図がなければプリミティブ型の代入などの際に型を書くことはありません。

const name = "John Doe"; // TypeScriptは代入内容からnameの型をstringと推論する
const age = 25; // numberと推論する

このようなコードの内容から型を推論する機能を型推論といいます。 そして、この型推論はコードの制御構造なども踏まえて働きます。 TypeScriptでは、文字列かもしれないし、nullかもしれないという値をstring | nullと表現することができますが(詳細は後述)、if文を用いてnullチェックすることによってstringに型のレベルで絞りこむことができます。

let nullableStr: string | null;

if (nullableStr != null) { // nullチェック
  // このif文のスコープ内では`nullableStr`はstringと推論される
}

他にもtypeofなどを利用してデータ型を特定すると型も絞り込まれます。

let stringOrNumber: string | number;
if (typeof stringOrNumber === "string") {
  // この中ではstringOrNumberはstringと推論される.
}

型注釈

プリミティブ型などを代入するだけのケースではほぼ利用しないかと思いますが、複雑な型の場合の変数の宣言や関数の引数・戻り値に対して注釈をつけることができます。 具体的には以下のように所定の場所に: 型名の形で注釈を書きます。

const name: string = "John Doe";
const age: number = 25;

const func = (val: string): boolean => {
  return true;
}

このように注釈を書くと、異なる型の値の代入などに対してエラーを吐くようになります(stringと型注釈をつけた変数に対して数値を代入しようとするなど)。推論より注釈が優先しているということです。

このような推論や注釈は最終的なJavaScriptへのトランスパイル時などに利用されていますが、プログラマーの開発体験という意味でも良い効果があります。コードを書く際に、エディタのLSPを通じてこれらの恩恵を受けています。存在しないプロパティへのアクセスに警告の波線が引かれたり、プロパティへのアクセスに対してサジェストが効くのは型推論と型注釈のおかげです。

型エイリアス

TypeScriptでは、プリミティブ型などの所与の型から新しい型を作る(そしてそれらの型からまた別の型を作る)という型レベルでのプログラミング機能が提供されており、型もまた変数のように宣言できます。typeという接頭辞をつけて以下のように宣言します。 これらは型推論や型注釈の対象となるので、上記のプリミティブ型の例と同じように型注釈に利用することもできます。

type Str = string;

type User = { // 文字列型のnameと数値型のageというプロパティを持つUserというオブジェクトの型定義
  name: string;
  age: number;
  role?: string; // オプショナルなプロパティには?をつける
}

const userWithRole: User = {
  name: "John Doe",
  age: 25,
  role: "admin"
} // これもOKだし

const userWithoutRole: User = {
  name: "Jane Doe",
  age: 30
} // これもOK

type Func = (a: string, b: number) => boolean; // 文字列型のa、数値型のbを引数に持ち、真偽値型を返却する関数の型定義

type NumberArray = number[]; // 数値型の配列

const numberArray: NumberArray = [1, 2, 3, 4, 5];

リテラル

また、TypeScriptは具体的な真偽値型・文字列・数値を指定するなどすることで単なるプリミティブ型などより詳細な型を定義することができます。

type IsValid = true;
type AnimalType = "Dog" | "Cat"; // "Dog"と"Cat"以外の文字列を代入できない型(パイプ(|)の役割については後述)
type HttpStatus = 200 | 400 | 404 | 500; // 200,400,404,500以外の値を代入できない型になる
type Url = `https://${string}`;

タプル

JavaScriptではタプル用のデータ型が用意されているわけではありませんが、配列を疑似的にタプルとして利用することがあります。TypeScriptでは明示的に型をつけることでより型安全にタプルぽく配列を利用できるようになります。

type Tuple = [string, number, boolean]; // 配列のn番目の型を指定するという使い方ができる
const tuple: Tuple = ["Hello", 1, false]; 

Union Type(ユニオン型)

複数の型のうち、いずれかの型を表現することができます。2つ以上の型をパイプ記号(|)で繋げて書きます。

type NumberOrUndefined = number | undefined;

配列のユニオン型は書き方に注意が必要です。

type List = string | number[]; // これは「文字列型」 もしくは 「数値の配列」と解釈される
type List = (string | number)[]; // 「文字列型」もしくは「数値型」のデータの配列を表現する場合はこう

Intersection Type(交差型)

複数の型についていずれもを満たす型を定義することができます。2つ以上の型を&でつなげて書きます。

type Animal = {
  name: string;
}

type Dog = Animal & { // Animal+鳴くという処理が追加された型を定義
  bark: () => void;
}

なお、stringとnumberの交差型などあり得ない型にはneverという型が割り振られます。

readonly

読み取り専用プロパティにはreadonlyという修飾子をつけることができます。

type Human = {
  readonly name: string;
}
const human: Human = {
  name: "John Doe"
};

human.name = "Jane Doe"; // Typeエラー

constで変数宣言すればいいのではないかと思われるかもしれませんが、JavaScriptのconstはオブジェクト型であることが不変になるのであって、そのオブジェクトのプロパティは不変ではないという言語仕様があります。オブジェクトのプロパティを不変にするという機能がTypeScriptによって提供されています。

const アサーション

readonlyは修飾したプロパティにしか効きません。複数のプロパティを持つ場合や例えば、オブジェクトの入れ子になっているなどの場合に再帰的に読み取り専用にしたいことがあります(エラーメッセージ、コードなどをまとめて定義した定数オブジェクトなど)。そういう場合はconstアサーションが有効です。オブジェクト に対してas constというアサーションを書くことで入れ子を含む全てのプロパティに対してreadonlyを設定できます。

const user = {
  name: "John Doe",
  age: 25,
  address: ["Tokyo", "LA"]
} as const;

satisfies

TypeScript 4.9からは satisfies という演算子が追加されました。式 satisfies 型と書くことで、式の内容が型に合致しているかを見てくれます。

type User = {
  name: string;
}
const user = { name: "John Doe" } satisfies User;

型注釈をつけると、型推論が効かなくなるのに対して、safisfiesは型推論しつつ、型とのマッチも見てくれる点が特徴です。 具体的なユースケースなどは以下の記事などが参考になります。

https://9sako6.com/posts/why-typescript-satisfies-operator

型ガード

nullチェックやtypeofを利用した分岐のような型の絞り込み機能を独自定義の型に対しても利用したい場合があります。この場合は型ガードが利用できます。型ガードは関数に引数 is 型という戻り値を持たせ、この関数を通すことでTypeScriptはその型であると推論させる機能です。

type User = {
  name: string;
}

const isUser = (val: unknown): val is User => { // 戻り値を`引数 is 型`と書き
  return val != null
    && typeof val === "object" 
    && "name" in val 
    && typeof val.name === "string"; // valのプロパティチェックなどの結果を返却
}

const val: any = ...;
if (isUser(val)) {
  // この中ではvalはUser型であると推論される
}

注意点としては、型ガードはプロパティチェックなどのJavaScriptの実装の正しさを 保証しない という点です。引数 is 型という戻り値を持つ関数がtrueになるスコープではその型だと見なすという機能にすぎません。例えば実際にはまったくプロパティチェックを行わない実装であっても型推論が効いてしまいます。

type User = {
  name: string;
}

const isUser = (val: unknown): val is User => { // 戻り値を`引数 is 型`と書き
  return true; // 常にtrueを返す実装をする
}

const val: any = ...;
if (isUser(val)) {
  // この中ではvalはUser型であると推論される(本当にnameというプロパティを持つオブジェクトである保証は全くないが)
}

このあたり、静的型付けの言語一般にありそうなインスタンスの型チェックを期待していると痛い目を見る可能性があります。

Utility Types

このあと型レベルプログラミングについて紹介しようと思いますが、型レベルプログラミングについて理解していなくても利用できる汎用的な型の操作に関するUtilityがTypeScriptから提供されているのでいくつか紹介します。

Pick

ある型に対して、一部プロパティのみを持つ型を定義するにはPickを利用できます。

type User = {
  name: string;
  age: number;
  address:string[];
}

type OnlyNameAndAge = Pick<User, "name" | "age"> // {name: string, age: number}

Omit

反対に、一部プロパティを除外する型を定義するためにはOmitを利用できます。

type User = {
  name: string;
  age: number;
  address:string[];
}

type WithoutAddress = Omit<User, "address"> // {name: string, age: number}

ReturnType

関数の戻り値を抽出したい場合はReturnTypeを利用できます。

const func = (): boolean =>{ };

type FuncReturnType = ReturnType<typeof func>; // boolean.

Awaited

Promise<string>のようにPromiseにラップされた中身を取り出します。

type User = {
  name: string;
}
type UserEndpointResponse = Promise<User>

type AwaitedUser = Awaited<UserEndpointResponse> // {name: string}

その他

他にもいろいろあります。

https://www.typescriptlang.org/docs/handbook/utility-types.html#handbook-content

つまりTypeScriptを利用して型安全に開発しようというのは

これらを活用して型定義を作り、型注釈をつけ、型推論を働かせることで、まず、オブジェクトや関数の引数・戻り値をガッチリと決める(型レベルプログラミング)。
そしてそれらに従ってJavaScriptのコーディングを進めていくと必然的にnullチェックやプロパティの存在チェックなどが実装され、危険なプロパティアクセスがなくなるため、結果トランスパイルされたJavaScriptの品質もよくなるよね、というのがTypeScriptを使って型安全にしようということです。

「型安全」なTypeScriptの限界

TypeScriptを利用しているとJavaScriptに型がついたような錯覚を得るのですが、これは錯覚に過ぎないという点に注意が必要です。
ブラウザやNode環境で実行する場合、TypeScriptそのものではなく、トランスパイルされたJavaScriptが動いていることに留意しなければなりません(DenoやBunの登場によってトランスパイルして使うものという前提は崩れつつありますが)。ランタイムでは型情報が失われています。
先の型ガードの章の注意点と同じなのですが、例えば、以下のようなコードを書くとき、TypeScriptは幻想にすぎないことが明らかになります。

type User = {
    name: string;
    age: number;
}

const findUser = (): User => {
    const user = {
        name: "John Doe",
        age: 25,
        password: "password"
    };
    return user;
}

const user = findUser(); // userは User型
console.log(user)

console.log()の結果を見ると以下のようになっています。

{
  "name": "John Doe",
  "age": 25,
  "password": "password"
}

これは静的解析ではエラーになりません。findUserはnameageプロパティにもつUserという型のオブジェクトを返すはずが、passwordが露出してしまいました。これがAPIのエンドポイントのハンドラであったらセキュリティインシデントに至ってしまいます。 TypeScriptの型情報はJavaScriptのオブジェクトの実体には作用しないのでこのようなことが起こります。実際に、このTypeScriptのコードをトランスパイルしてみると、以下のようになります。

"use strict";
const findUser = () => {
    const user = {
        name: "John Doe",
        age: 25,
        password: "password"
    };
    return user;
};
const user = findUser();
console.log(user);

処理内容はTypeScriptで書いたままですね。「TypeScriptの型にpasswordがないから、JavaScriptのオブジェクトからpasswordというプロパティを削除してくれる」、わけではありません。

こうした考慮は型によっては事前に弾くことができず、例えばテストを充実させて発見していくといった対処が必要となります。

型レベルプログラミング

TypeScriptは型を扱うための機能が充実しており、「型に関して」プログラムを書くことができます。よく利用するものや利用例を紹介します。 なお、全般的な注意点として、型レベルプログラミングはJavaScriptのプログラミングとは別物と考えるのがよいです。

①JavaScriptの変数、②所与の型、③独自定義の型をインプットとし、型定義がアウトプットとなるJavaScriptとは別のプログラミングをしていると考えてください。

typeof

JavaScriptのtypeofとは 全く 別で、TypeScriptにもtypeofという演算子が存在します。JavaScriptの 変数 から型を抽出するために利用します。

const user = {
  name: "John Doe",
  age: 25
}

type User = typeof user; // { name: string; age: number }

例えば、constアサーションを利用していると、readonlyのリテラルとして定義されます。

const user = {
  name: "John Doe",
  age: 25
} as const;

type User = typeof user; // {readonly name: "John Doe"; readonly age: 25 }

keyof

オブジェクトの型からプロパティ名を取り出すのにkeyofという演算子を利用できます。

type User = {
  name: string;
};
type UserKey = keyof User; // "name"

複数のプロパティを持つ場合はユニオン型になります。

type User = {
    name: string;
    age: number;
}

type UserKey = keyof User; // "name" | "age"

このように得た型定義を、例えば、コード値からメッセージを検索する関数の引数や戻り値の型定義に利用すると型安全だよね、ということです。

Mapped Types

オブジェクトのプロパティ名などを型レベルで操作するにはMapped Typesが有効です。例えば、任意の文字列をキーに持つオブジェクトの型を定義する際は以下のようになります。

type OnlyBoolean = {
  [key: string]: boolean;
};

const boolObject: OnlyBoolean = {
  hoge: true,
  fuga: false,
}

リテラルのユニオンを利用することでいくつかのプロパティを指定するといったことも可能です。

type Language = "ja" | "en" | "fr";

type Supported = {
    [K in Language]: boolean; // Language型のそれぞれの値をキー、booleanをバリューにもつオブジェクトの型
}

const supported: Supported = {
    ja: true,
    en: false,
    fr: false
}

インデックスアクセス

プロパティ名や配列の各要素へのアクセスは以下のように定義できます。

type Foo = {
  name: string;
}

type Name = Foo["name"]; // string

type UserArray = User[];

type Item = UserArray[number]; // User

Generics

より一般的な型を定義するための手段としてGenericsがあります。以下のように書きます。

type User<T> = {
    name: string;
    age: number;
    hoge: T;
}

const userWithString: User<string> = {
    name: "John Doe",
    age: 25,
    hoge: "string"
}

const userWithObject: User<{
    fuga: number
}> = {
    name: "John Doe",
    age: 25,
    hoge: {
        fuga: 1
    }
}

先ほどのUtility Typesの章で利用していたのもこのGenericsです。

独自の型定義で利用するユースケースとしては、エラーメッセージの設計などがあるかと思います。 共通のエラーコードやメッセージなどをGenericsを利用して定義しておき、Genericsで各エラーによって内容を拡張するといった利用法がありえます。

type ValidationError<T> = {
    code: string;
    message: string;
    detail: T;
}

type PlanValidationError = ValidationError<{ link: string }>;

const validatePlan = (plan: string): PlanValidationError => {
    return {
        code: "PLAN_ERROR",
        message: "You need to subscribe to the Pro plan.",
        detail: {
            link: "https://example.com/see-more-detail"
        }
    }
}

Conditional Types

条件によって型を切り替える際は、三項演算子的にT extends U ? X : Yという形で表現できます。

type IsString<T> = T extends string ? true : false; // Tがstringの場合はtrueそうでない場合はfalseという型になる

infer

Conditional Typesの中でGenericsの内容によって型を定義する際はinferが利用できます(型レベルプログラミングの中で一時変数を宣言するイメージ)。

type ArrayOf<T extends any[]> = T extends (infer U)[] ? U : never; // T が U[]という配列の場合はUを返す

type NumberArray = number[];

type ArrayOfNumberArray = ArrayOf<NumberArray>; // number

type First<T extends any[]> = T extends [infer A, ...infer Rest] ? A : never; // 配列の最初をA, 残りをRestとして Aを返す

type Tuple = [number, string, undefined];

type TupleFirst = First<Tuple>; // number

// 文字列の抽出などもできる
type Url = `https://${string}`;
type Domain = Url extends `https://${infer R}` ? R : never; // string

いくつか組み合わせた実践例

keyofとtypeofとindex accessの利用例

例えば、バリデーションの共通定義を作り、コードやメッセージを型定義として得ることができます。

const ValidationError = {
    INVALID_PRICE: {
        code: "0001",
        message: "有効な価格を入力してください。"
    },
    INVALID_NAME: {
        code: "0002",
        message: "有効な名前を入力してください。"
    }
} as const;

type ValidationErrorCode = typeof ValidationError[keyof typeof ValidationError]["code"] // "0001" | "0002"
type ValidationErrorMessage = typeof ValidationError[keyof typeof ValidationError]["message"] // "有効な価格を入力してください。" | "有効な名前を入力してください。"

Union TypesとMapped Typesの利用例

ある型の各プロパティのnullを取り除く型定義を作ることでデフォルト値の設定を強制できます。

type RequiredNonNull<T> = {
    [P in keyof T]: T[P] & {} // TのバリューをT[P] & {}に置き換える(nullが除外される)
}

type NullableUser = {
    name: string | null;
    age: number | null;
}

type NonNullableUser = RequiredNonNull<NullableUser>; // { name: string; age: number; }

const userParser = (nullableUser: NullableUser): NonNullableUser => {
    return {
        name: nullableUser.name ?? "John Doe",
        age: nullableUser.age ?? 25
    }
}
記事一覧に戻る