👌

一日一処: TypeScriptでオブジェクトプロパティの型をプロパティ名によって、動的に変更する長旅に出た話

2024/03/29に公開

長かった旅

本当に長く、発想力がないせいで、解決まで2,3日時間を要してしまった。シンプルに考えると、非常に簡単な方法だった。慣れや発想力、なにかいろいろ足りていないと感じたが、同じような部分で躓く人もいないわけではないと感じたので、記事にした。

オブジェクトのプロパティ

TypeScriptでは以下のように、オブジェクトプロパティの値に対して型を設定できる。これはTypeScriptに触れていれば、誰しも知っていることだろう。

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

インターフェースでも変わりなくできる。

interface User {
    id: number
    name: string
    age: number
}

プロパティ名によって値の型を定義

前述のそれぞれの型を見ると、numberが重複している。本来、プロパティそのものの意味合いが異なるため、この型が同一であるという風にみなさないが、仮に電話番号の様に、自宅・携帯・会社のような同じ内容物だがラベリングが異なるデータについては、型が重複して見えてしまう。

type UserContact = {
    home: number
    mobile: number
    office: number
}

上記の例だと3つのラベルパターンしか無いが、これが複数ある場合、行を複製したり、この型が非常に長くなってしまうかもしれない。
そのため、この3つのプロパティ名を個別に分離し、すべてがnumberである場合の記述を行うとこの様になる。

type ContactLabel = 'home' | 'mobile' | 'office'
type UserContact = {
    [Key in ContactLabel]: number
}
const contact: UserContact = {
    home: 117,
    mobile: 110,
    office: 119,
}

ユニオン型でプロパティ名を定義できれば、よりシンプルに追加削除ができるようになった。だが、ここまでは、よくあるパターンで、今回、旅に出たのは更にこの先だ。

複数パターンのプロパティの動的定義

例えば、以下のように、通常のラベルと、その先頭にgetをつけたラベルに対応するGetterのメソッド名の型を定義し、オブジェクトのプロパティでMapped typeを行う。

type ContactLabel = 'home' | 'mobile' | 'office'
type GetterLabel = `get${Capitalize<ContactLabel>}`
type UserContact = {
    [Key in ContactLabel]: number
    [Key in GetterLabel]: () => number
    // -> A mapped type may not declare properties or methods.
}

だが、これは、コメントの通りエラーとなる。Mapped typeを一つの複数定義することはできないのだ。これを解決するには、おなじみのConditional typeだ。

type ContactLabel = 'home' | 'mobile' | 'office'
type GetterLabel = `get${Capitalize<ContactLabel>}`
type AllLabel = ContactLabel | GetterLabel
type UserContact = {
    [Key in AllLabel]: Key extends ContactLabel
        ? number
        : Key extends GetterLabel
            ? () => number
            : never
}
const contact: UserContact = {
    home: 117,
    mobile: 110,
    office: 119,
    getHome() { return this.home },
    getMobile() { return this.mobile },
    getOffice() { return this.office },
}

複雑ではあるが、一つのMapped typeを使用して、2つのラベルに対応する値の型を制御することができた。致し方ないことだが、明確にしようとすると、型定義が増えていくのは辛いものがある。

まだ終わらない不特定のプロパティへの制御

一度、Getterの存在を忘れる。
今度は、この型を使用するときに、任意のキーを設定できるようにしたいというものだ。例えば、以下のようにすれば、どのようなものであってもすべて受け入れることができる。

type UserContact = {
    [key: string]: number
}

これは当たり前で、初歩の初歩だろう。ただし、前述までの3つのプロパティ名は保持したい。それ以外は、任意のものを追加させるといったところだ。
シンプルに考えるとこうなるだろう。

type UserContact = {
    [key: string | ContactLabel]: number
    // key: string
}

ユニオン型でプロパティ名を定義すると、これは文字列として認識されてしまう。困る。ちなみに、inやインターセクションを用いても同様だ。これは、困るがstring自体はこの様に定義する必要があるそうだ。

type UserContact = {
    [key in ((string & {}) | ContactLabel)]: number
    // key: string
}

const contact: UserContact = {
    // Type '{}' is missing the following properties
    // from type 'UserContact': home, mobile, office
}

const myContact: UserContact = {
    home: 117,
    mobile: 119,
    office: 110,
    holidayHome: 118,
}

これによって、必須としたいラベルを要求することができ、任意のラベルを追加することもできるようになった。更にここからややこしくなってしまう。

不特定のプロパティのみ異なる型を定義

不特定のプロパティを追加することができたが、不特定であるがゆえに、何でも入力できるようにしたい場合、例えばこれを文字列の型にする。そのため、定義済みの3つのラベル以外のプロパティでは、文字列の値とするようにConditional typeを用いて行う。

type UserContact = {
    [Key in ((string & {}) | ContactLabel)]: Key extends ContactLabel
        ? number
        : string
}

const myContact: UserContact = {
    // Type '{ home: number; mobile: number; office: number; holidayHome: string; }'
    // is not assignable to type 'UserContact'.
    // Property 'home' is incompatible with index signature.
    // Type 'number' is not assignable to type 'string'.
    home: 117,
    mobile: 119,
    office: 110,
    holidayHome: '118',
}

見ての通り、しっかりエラーになってくれる。ハゲそうだ。
想定しているのは、3つの値が数値で、任意のプロパティは文字列であるということだが、このオブジェクトが変数に代入できないと怒られてしまう。前述の例のようにユニオン型とユニオン型を組み合わせたユニオン型でのMapped typeであれば、問題なく、それぞれを区別していたが、(string & {})とのユニオン型でConditional typeを用いると、上記のようにすべてのプロパティ名が単純な文字列として認識され、Conditional typeでnumberになるプロパティは皆無となる。ここもっとも悩んだポイントだった。

常に初心に帰りシンプルな実装を行う

そして、今回の大目玉である前述の解決策は至ってシンプルだった。
どれだけ調べようとも、上記のような文字列型とユニオン型でConditional typeが想定した動きにならないということに関しての記事などは出てこなかった。それもそのはず。わざわざプロパティ名でMapped typeに文字列を組み込んだり、値の型をConditional typeにする必要なんてなかったのだ。
そう、ユニオン型を使えばいい。

type UserContact = {
    [Key in ContactLabel]: number
} | {
    [Key: string]: string
}

const myContact: UserContact = {
    home: 117,
    mobile: 119,
    office: 110,
    holidayHome: '118',
}

この答えに到達したとき、なぜもっと早く気付けなかったのかと2,3日無駄にしたことを悔いた。ただこれには、一つだけ懸念がある。今回実装していた内容は、「特定のプロパティ名での型定義とそれ以外のプロパティ名での型を異なるものとし、すべてのプロパティがオプショナルである」というものだ。
この型の例の場合、ユニオン型で、任意の文字列のプロパティを追加しているため、自動的にすべてがオプショナル風になる。そのため、いかが成り立ってしまう。

// 空のオブジェクトが代入可能
const contact: UserContact = {}
// 更に存在しなにも関わらず警告が表示されない
console.log(contact.home)

ここでIntersection typeにしてみればよいかと言われると、「どちらも」という定義となってしまうため、文字列とユニオン型でゆらぎ、Index signatureの互換性がないと言われてしまう。エディターでプロパティ名をサジェストさせたい場合には、すこし異なる方法を用いる必要がありそうだ。

おわりに

もっと素敵な方法や、書いている内容に誤りがある(または異なる解釈がある)場合は、コメントにてお知らせいただけると助かります。

Discussion