1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

高度な型操作 - Typescript & Angular 勉強会 #9

Last updated at Posted at 2020-11-04
1 / 6

ルックアップ型

以下のような型が定義されていたとします。

type APIResponse = {
    user: {
        userId: string
        friendList: {
            count: number
            friends: {
                firstName: string
                lastName: string
            }[]
        }
    }
}

この型の friends 部分だけを利用したい場合、その型はどのように書けるでしょうか。
普通に考えると、その部分の型を以下のように抜き出して、

type Frined = {firstName: string, lastName: string}

その型を APIResponse に組み込むのが良いと考えるでしょう。

type APIResponse = {
    user: {
        userId: string
        friendList: {
            count: number
            friends: Friend[]
        }
    }
}

しかし、上記のような手段が取れないようなこともあります。例えば、APIResponse型をSwaggerCodegenなどを利用して自動生成しているような場合は、自動生成コードに手を入れることはできないので、上記のような方法は使えません。

またもっとシンプルに、APIResponse型がnpmで取ってきたライブラリ内の型で、自分たちでは手を出せない、ということもあるでしょう。

そのような場合に有効なのがルックアップ型です。

この場合、以下のようにかけます。

type APIResponse = {
    user: {
        userId: string
        friendList: {
            count: number
            friends: {
                firstName: string
                lastName: string
            }[]
        }
    }
}

// ルックアップ型で型の部分集合へアクセス
type Friend = APIResponse['user']['friendList']['friends'][0]

このように、配列アクセスのような形で、型のメンバの部分型へとアクセスできるのがルックアップ型の特徴です。


レコード型

例えばエラーコードに対応したメッセージを定義したい、という場合、以下のように書くことが多いでしょう。

type HttpErrorCode = "400" | "404" | "500"
const messages = {
    "400" : "引数エラー",
    "404" : "見つかりません",
    "500" : "サーバーエラー"
}

もちろん上記は型安全ではありません。 messages には HttpErrorCode で定義していないエラーコードへのメッセージもかけてしまいます。

これを型安全にするのがレコード型です。

type HttpErrorCode = "400" | "404" | "500"
const messages: Record<HttpErrorCode, string> = {
    "400" : "引数エラー",
    "404" : "見つかりません",
    "500" : "サーバーエラー"
}

こうすることで、型安全性はもちろん、完全性まで手に入ります。それは以下のようなコードで発生するコンパイルエラーによりわかります。

type HttpErrorCode = "400" | "403" | "404" | "500"

// Property '403' is missing in type '{ "400": string; "404": string; "500": string; }' but required in type 'Record<HttpErrorCode, string>'. 
const messages: Record<HttpErrorCode, string> = { 
    "400" : "引数エラー",
    "404" : "見つかりません",
    "500" : "サーバーエラー"
}

上記のように、全ての取りうる値を満たさない限り、コンパイルエラーとしてくれます。

また、ほぼ同等の機能を有する、、、というかそれのさらに上位の機能を持つ型としてマップ型があります。


マップ型

レコード型で実現したコードと同等なコードをマップ型で書くと以下のようになります。

// Property '403' is missing in type '{ "400": string; "404": string; "500": string; }' but required in type 'Record<HttpErrorCode, string>'. 
const messages: {[K in HttpErrorCode]: string} = {
    "400" : "引数エラー",
    "404" : "見つかりません",
    "500" : "サーバーエラー"
}

こちらのコードでも、レコード型と同様にプロパティが足りない旨をコンパイルエラーとしてくれます。

ではマップ型独自の機能とはなんでしょうか。

マップ型とレコード型を見比べて明確に違うのは、定義部分が通常のクラス定義に近い形になっている、ということです。
見やすく書くと、以下のようになります。

type ErrorMap = {
    [K in HttpErrorCode]: string //オブジェクト定義っぽい
}

実際にここはオブジェクト定義となっており、例えばここに省略可能メンバを示す ? を入れるなど、独自の定義を挿入することができます。

type OptionalErrorMap = {
    [K in HttpErrorCode]?: string
}

実はこの機能を応用すると、既存の型を簡単に拡張できるようになります。
例えば、全てのフィールドを省略可能にする Partial、 全てのフィールドを必須にする Required、特定のKeyだけ抜き出す Pick などです。

これらをうまく使うと、無駄な多重定義をなくしてコードの重複を少なくすることができます。

例えばWebAPIのBodyに利用するクラスを考えます。
Post、Get、Patchなどの各APIに対応する方を考えると、非常に冗長な定義が発生します。

type UserPost = {
    name: string,
    birthday: Date
}

//Getには作成日が付加される
type UserGet = {
    name: string,
    birthday: Date,
    createdAt: Date
}

//Patchは任意のプロパティを省略可能
type UserPatch = {
    name?: string,
    birthday?: Date
}

しかし、これを合併型と、特殊なマップ型を利用すると、以下のようにできます。

type UserPost = {
    name: string,
    birthday: Date
}

//Getには作成日が付加される
type UserGet = UserPost & {
    createdAt: Date
}

//Patchは任意のプロパティを省略可能
type UserPatch = Partial<UserPost>

名前的型のシミュレート

Typescriptの型システムは構造的部分型である、という話は何度かしてきたと思います。改めて一言で解説すると、Typescriptは同じ名前と型のプロパティを持っている型同士を同一の型として扱います。

例えば以下のようにID型を定義したとしても、

type CompanyID = string
type OrderID = string
type UserID = string

このように異なる型同士でも代入ができてしまいます。

let orderID: OrderID = "123456"
let companyID: CompanyID = orderID //OK

これをコンパイルエラーとするためには、型定義に型のブランド化というテクニックを導入します。

type CompanyID = string & {readonly brand: unique symbol}
type OrderID = string & {readonly brand: unique symbol}
type UserID = string & {readonly brand: unique symbol}

一見、同じような型が定義されただけのように見えるかもしれませんが、Typescriptにおいて unique symbol型は特異的な性質を持ちます。unique symbol型は、アプリケーション内において絶対に衝突しない型となっています。つまり、この型だけはTypescriptの「構造的部分型」の範疇外で、このプロパティを含む型は同一のプロパティを持っていたとしても別の型として扱われます。

しかし、今度はCompanyIDにString型は代入できなくなりました。なので、文字列型をそれぞれの型にキャストする関数も新設します。

type CompanyID = string & {readonly brand: unique symbol}
const CompanyID = (id: string) => { return id as CompanyID }
type OrderID = string & {readonly brand: unique symbol}
const OrderID = (id: string) => { return id as OrderID }
type UserID = string & {readonly brand: unique symbol}
const UserID = (id: string) => { return id as UserID }

こうすることで、異なるIDへの割当がエラーになります。

let orderID: OrderID = OrderID("123456")
let companyID: CompanyID = orderID // Type 'OrderID' is not assignable to type 'CompanyID'.

このように素の型をできるだけ使わず、独自の型を定義し、異なる方への代入をコンパイラレベルで検知することで、安全性を大きく増すことが可能になります。


更に学習したい人へのリソース

勉強会資料のもととなった書籍です。実は内容としてはようやく半分と言ったところで、更に深い型の話もあります。

  • Typescript DeepDive

    • 基礎からマニアックな事柄までカバーされたTypescriptのオンライン本です。こちらもより深く学ぶのにおすすめです。
  • TypeScript 型の課題集

    • Typescriptの型システムについて学ぶ課題集。はっきり言ってかなりマニアックで、実用性があるかと言われるとライブラリ作者以外にはないかも、、、。ただパズルとしては面白く、型とコンパイラでここまでできるのか!というのの例には良いのかなと。
1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?