😊

一日一処: TypeScriptでオブジェクトのプロパティに任意のキーと必須のキーを設定し、それぞれのキーによって異なる値の型を設定する

2024/07/07に公開

TypeScriptでの型定義

TSに触れていると、いくつか前例を調べても見つからないパターンがある。また、意図するものが見つかったとしても、適切に動作するとは限らないこともある。
今回は、TSにおけるオブジェクトで、必須のキーと任意のキーを設定し、それぞれに異なる値の型を与えた苦戦した方法を紹介する。
この記事は、過去の記事を更に深堀り、より厳密に動作するように改良を加えた作業となる。

通常のオブジェクト型定義と記事の前提

通常、型を定義する場合、typeinterfaceを使用する。今回は、記事全体を通して、typeでの記法に統一する。
一般的にUserの型を定義したいとき、以下のようなものを想定することが多いだろう。

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

加えて、それぞれを任意としたい場合、以下のようなものになるだろう。

type User1 = {
    name?: string,
    age?: number,
}
// または
type User2 = Partial<{
    name: string,
    age: number,
}>

任意かどうかは別として、この記述をUnionによって、整理すると以下のようになる。

type UserKey = 'name'|'age'
type User = {
    [Key in UserKey]: Key extends 'age' ? number : string
}
const user: User = {
    name: 'bob',
    age: 24
}

実際の開発では、あまり用いることのない記述だろう。値の型のパターンが複雑になったりするため、あまり好ましい方法ではない。ただ、これに加えて、このUserには、設定時に任意の異なるキーを設定できるようにしたい場合どうなろうだろうか。
たとえば、型定義にない、addressというキーを追加したいとする。ただし、このキーは、Userの型としては、任意に設定するものだとした場合、型定義には、以下のような記述を書くことになるだろう。

type User = {
    [key: string]: string
}
const user: User = {
    name: 'bob',
    address: 'Japan',
}

これは、例として書いているため、ageの存在については、無視するものとして、このように書けば、nameaddressも許容できる。
ただし、この記述は、user.とエディターで書いても、nameaddressを記述できる補完の候補として、表示してくれない。つまり、型の推論として、すべてキーがstringとなってしまうため、正確なキーが表示されないわけだ。これだと、開発の効率が著しく低下するだろう。

既定のキーと任意のキーを共存させる型への挑戦

結論を書く前に、先に試してみて、結果的に思うものにならなかったものを紹介する。前述の話の通り、やりたいこととしては、以下の通りだ。

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

type User = {
    [key: string]: string
}

を共存させたい。

シンプルに書くならば、以下の通りだ。

type User = {
    name: string,
    age: number,
    [k: string]: string,
}

ただ上記は、エラーとなる。理由は、ageキーの値が最終行の[k: string]: stringに一致しないからだ。では、以下はどうだろうか。

type UserKey = 'name'|'age'
type User = {
    [Key in UserKey]: Key extends 'age' ? number : string
    [k: string]: string
}

これも、競合が起きてしまい、エラーが発生する。つまり、Union型でのキーと文字列型での共存はできないように考えられる。他に試した方法としては、これらがある。

type UserKey = 'name'|'age'|(string&{})
type User = {
    [Key in UserKey]: Key extends 'age' ? number : string
}
const user: User = {
    name: 'test',
    age: 10,
}

上記は、Unionに文字列型を加えた形だ。一見問題なさそうに見えるが、ageキーによる条件が一致しなくなり、すべての値の型が文字列となるが、以下のようにすべてを文字列にしてもエラーとなる。

const user: User = {
    name: 'test',
    age: '10',
}

理由は、Unionに記述したstring&{}がすべてのキーを吸収するため、ageだろうが、nameだろうが、すべてstring判定となり、extendsによる条件は一致しなくなる。だが、定義上は、ageは数列として設定されているため、文字を挿入しようとしても怒られる。つまり、ageには何を入れても怒られる仕様だ。困った。
ちなみにだが、いかのような共存パターンも挑戦した。

type User = {
    name: string,
    age: number,
} | {
    [k: string]: string,
}
const user: User = {
    name: 'bob',
    age: 24,
    job: 'engineer'
}

このパターンは、先程までと違い、エラーが発生しない。つまり、実現したかったものに近づけたか、そのものだ、という話だが、これの場合、userのプロパティとして候補に上がるのは、nameとageのみとなる。user.jobと参照しようものなら、親の仇と言わんばかりにエディターから怒られる羽目になる。
このできそうでできないもどかしさを解消するために、実現したのが次項の内容だ。

固定のキーと任意のキーを設定するオブジェクト定義

これから説明する内容より適切な方法があれば、ぜひ教えてほしい。ここまでに記述した内容を数日間考え、最終的に私が到達した答えとなる。それは、型推論と関数を用いることだ。
まずは、振り返りとして、実現したいものを確認しよう。

// User型には、nameが必須であり、ageが任意で、
// それぞれ、stringとnumber型となる。
type User = {
    name: string,
    age?: number,
}
// この型に対して、任意のキーを追加できるようにしたい。
// 例としては、以下のようなjobキーを与えることができ、
// 任意のキーは常に文字列型とする。
const user: User = {
    name: 'bob',
    age: 24,
    job: 'engineer'
}
// ただし、この記述だと、jobキーがエラーになり、
// 型宣言側でも調整することが難しい。

上記の方向性から、私が行った実装は、以下の通りだ。

type UserStringProp = 'name'
type UserNumberProp = 'age'
type User<T> = {
    [K in (keyof T)]: K extends UserStringProp ? string
        : K extends UserNumberProp ? number
        : string
}

function defineUser<T>(user: User<T>) {
    return user as User<T>
}

const user = defineUser({
    name: 'bob',
    age: 24,
    job: 'engineer',
})

このように実装することで、文字列と数列の固定したキーの実現を伴って、任意のキーにおける文字列の値を設定することができるそのため、以下のコードはエラーとなる。

const user = defineUser({
    name: 'bob',
    age: 24,
    job: 'engineer',
    score: 100,
})

任意のキーscoreが数値を渡されているため、文字列ではないということでエラーとなる。実現したいことに近づいた。
このコード自体は、大きな問題もなく、先ほどと異なりuser.jobuser.nameが適切に候補として表示され、呼び出してもエラーにはならない。しかし、nameとageが必須でなくても問題ないため、以下の内容でもエラーは発生しない。

const user = defineUser({
    // name: 'bob',
    // age: 24,
    job: 'engineer',
})

しかし、このままではUserをUserたらしめないため、できれば、この2つのキーは必須としたい。そのため、上部で記述したキー名の型を一つにまとめ、Userの型に加える。

type UserStringProp = 'name'
type UserNumberProp = 'age'
type UserRwquiredKey = UserStringProp|UserNumberProp // 追加
type User<T> = {
    [K in (keyof T)|UserRwquiredKey] // 変更
        : K extends UserStringProp ? string
        : K extends UserNumberProp ? number
        : string
}

function defineUser<T>(user: User<T>) {
    return user as User<T>
}

const user = defineUser({
    name: 'bob',
    age: 24,
    job: 'engineer',
})

このように、型推論とジェネリックの合せ技で、必須としたいキーの設定ができれば、nameとageが欠けた場合にエラーが発生するようになった。
エディター上でも補完の候補としてプロパティ名が表示されることになるため、(user as any).jobのような不快な記述は消すことができるだろう。
私の技術では、関数で型推論を無理やりねじ込み、その後、型情報を任意のキーも含めて、動的に生成する方法しか思いつかなかった。本当は、型制御のための関数を定義せずに型のみで終わらせたかった。実際にJSに変換されると、意味のない関数となってしまうので、あまり美しいとは言えないだろう。
Stackoverflowでも色々調べたが、前述の(string&{})|'key'{[K in Keys]: any}|{[K: string]: any}のパターンしか見つけることができなかった。より最適な手法があれば、コメントにて教えてほしい。

Discussion