🍀

【TypeScript】オプショナルな型を含むオブジェクト型から存在する型だけを抽出して利用したい

に公開
2

はじめに

ある個人開発で「オプショナルな型を含むオブジェクト型から存在する型だけを抽出して利用したい」場面がありました。

具体的には以下のようなオブジェクト型です。

export type quizChoicesType = {
    one: quizChoiceContentType;
    two: quizChoiceContentType;
    three: quizChoiceContentType;
    four?: quizChoiceContentType;
    five?: quizChoiceContentType;
    six?: quizChoiceContentType;
    seven?: quizChoiceContentType;
    eight?: quizChoiceContentType;
};

これはクイズゲームの設問数(回答選択肢)になります。
基本的には各問ごとに3つの選択肢としながら、最大8つの選択肢までを用意できるようにしたかったのです。

各種クイズゲームのデータ(問題及び回答、得点)は別途jsonファイルで用意していて、今回事例とする個人開発はそれを読み込んでクイズを表示するSPA(React 19)です。

今回、本記事で紹介するのは筆者が「クイズデータの各クイズにおける設問数を柔軟に表示(=オプショナルな型を含むオブジェクト型から存在する型だけを抽出)したい」という目的から実装した内容となります。

抽出するためのカスタムフック

以下が当該カスタムフックとなります。
(※これは型システム的に問題のある記述内容です)

  • useConvertTargetType.ts
export const useConvertTargetType = () => {
    const { quizCollectAnswerScores } = useContext(QuizCollectAnswerScoresContext);

    // quizCollectAnswerScores(オブジェクト)の中に key があるかチェックして
    // あれば「既に存在が保証されている型」を key に指定(型アサーション: 型推論の上書き)
    // なければ undefined
    const convertTargetType = (key: string): keyof quizChoicesType | undefined =>
    key in quizCollectAnswerScores ? (key as keyof quizChoicesType) : undefined;

    return { convertTargetType }
}
※ 後述する問題点を解消した修正版

key in quizCollectAnswerScoresの部分を、Object.hasOwn(quizCollectAnswerScores, key)に変更しています。

export const useConvertTargetType = () => {
    const { quizCollectAnswerScores } = useContext(QuizCollectAnswerScoresContext);

    const convertTargetType = (key: string): keyof quizChoicesType | undefined => {
    return Object.hasOwn(quizCollectAnswerScores, key) ?
            (key as keyof quizChoicesType) : undefined;
    }

    return { convertTargetType }
}
  1. 処理結果として、オブジェクト型からプロパティを(文字列リテラル)型として抽出
const convertTargetType = (key: string): keyof quizChoicesType | undefined => ...

カスタムフックの処理結果として、オブジェクト型からプロパティを(文字列リテラル)型として抽出したもの(例:one,two,three...eightまたはundefinedを返すようにしています。

  1. in 演算子でチェック
    中のロジックに関しては、in 演算子を用いてquizCollectAnswerScores(オブジェクト)の中に key があるかチェックして、あれば「既に存在が保証されている型」を key に指定し、なければ undefinedを返す、というものです
// convertTargetType の処理内容
key in quizCollectAnswerScores ?
    (key as keyof quizChoicesType) :
    undefined;

quizCollectAnswerScoresContext APIを使ったグローバルステートでして、以下のように動的に設定できるようにしてあります。

export type quizCollectAnswerScoresType = {
    /* key(プロパティ名)は(オブジェクトの)ブラケット記法で動的に命名 */
    [choiceLabel: string]: string[];
};

今回この部分も大切な役割を果たしています。
以下のコードでは、取得したデータの内容から設問項目名(例:one,two,three...eight)を取り出し、quizCollectAnswerScoresTypeに即したオブジェクトを生成しています。

const { setQuizCollectAnswerScores } = useContext(QuizCollectAnswerScoresContext);

// getQuizData は親コンポーネントにて use API を使って取得したクイズゲームのデータ props
const quizDataChoices: quizChoicesType[] = useMemo(() => getQuizData.map(quizData => quizData.choices), [getQuizData]);

/* 設問数(getQuizData.length)に応じた空要素('')を用意 */
const scoreEntriesAry: string[] = new Array(getQuizData.length).fill('');

const choiceLabel: string[][] = Object.values(quizDataChoices).map((eachQuizChoice, i) => {
    if (i === 0) {
        /* 設問項目名(例:'one' | 'two' | 'three')を抽出 */
        return Object.keys(eachQuizChoice);
    }
    }).filter((eachQuizChoice): eachQuizChoice is string[] => typeof eachQuizChoice !== 'undefined');

/* quizCollectAnswerScores の生成 */
/* 2次元配列(string[][])を1次元配列(string[])に変換して処理を進める */
const theChoices: quizCollectAnswerScoresType[] = choiceLabel.flat().map(label => {
    return {
        /* key(プロパティ名)は(オブジェクトの)ブラケット記法で動的に命名 */
        [label]: scoreEntriesAry
        }
    });

/* Object.assign(target, src) : {}(空オブジェクト)を用意し、スプレッド演算子で展開した theChoices の中身で上書き(格納)する */
const objMarged_newChoices: quizCollectAnswerScoresType = Object.assign({}, ...theChoices);
setQuizCollectAnswerScores(objMarged_newChoices);

この工程を経て、先に載せた以下処理が成り立つようになります。

key in quizCollectAnswerScores

上記の件は、コメントにてご教示いただきました。@ghaaj さんありがとうございます!


構造的部分型によって、期待するプロパティを持っていれば追加のプロパティがあっても型チェックを通過してしまいます。
例えば、quizChoicesTypeに定義されていない余分なプロパティ(例:isHoge: true)を持つオブジェクトも、必要なプロパティ(one,two,three...eight)さえ持っていれば同じ型として扱われます。

in演算子では「型の絞り込み(Narrowing)」が起きないようになっていて、かつ「継承元のプロパティも引き継いでしまう」ため、余分なキー・プロパティを含んでいる場合にTypeScriptは同じとみなしてしまいます。

しかも、JavaScriptには「すべてのオブジェクトが持つ特殊なプロパティ__proto__」があり、オブジェクトのプロトタイプ(継承元)を参照するため、これにより型システムと実行時の値に不一致が生じます。

コメントで教示いただいだ内容をもとに修正したのが以下です。

const convertTargetType = (key: string): keyof quizChoicesType | undefined =>
- key in quizCollectAnswerScores ? 
+ Object.hasOwn(quizCollectAnswerScores, key) ?
(key as keyof quizChoicesType) : undefined;

Object.hasOwnを用いることで、オブジェクト自身のプロパティだけをチェックし、プロトタイプチェーンは検索しないため先のような問題を回避できます。

さいごに

筆者の知識・スキル不足で冗長気味かつ間違った記述や説明もあるかもしれません。
何か気づいたり、気になったりされた方はコメントなどでご教授いただければ幸いです。

ここまで読んでいただき、ありがとうございました。

Discussion

ghaajghaaj

通りすがりに失礼します。もっと良くできそうなところがあったのでコメントさせていただきます。

  • 返り値の型は、直接アノテートできます。
    - const convertTargetType: (value: string) => keyof quizChoicesType | undefined = (value: string) => {
    + const convertTargetType = (value: string): keyof quizChoicesType | undefined => {
    
  • valuestringだと保証されているので、新たに変数に置かないで大丈夫です。
    - const type: string | null = value;
    - if (type !== null && type in quizCollectAnswerScores) {
    -     const targetKey = type as keyof quizChoicesType;
    + if (value in quizCollectAnswerScores) {
    +    const targetKey = value as keyof quizChoicesType;
          return targetKey;
      }
    

そうすると、普通の関数にしてみても私はすっきりして見やすいと思います。

const convertTargetType = (key: string) =>
    key in quizCollectAnswerScores ? (key as keyof quizChoicesType) : undefined;

ただ少し気をつけたいのは、構造的部分型の性質としてオブジェクトに余分なキーが含まれるかもしれないこと、in演算子はプロトタイプチェーンも遡ってしまうことです。これは、Object.keys()string[]を返すようになっていること、in演算子でNarrowingが起こらないようになっていることの一つの理由です。
前者はこちら側で安全性を保証できるとしても、後者は少し厄介です。たとえば、プロトタイプチェーンのキー名を渡す以下のコードでは型と値が乖離してしまいます。

const convertTargetType = (key: string) =>
    key in quizCollectAnswerScores ? (key as keyof quizChoicesType) : undefined;

const choiceType = convertTargetType("__proto__")
//    ^? const choiceType: "one" | "two" | "three" | "four" | "five" | "six" | "seven" | "eight" | undefined
// 実際には __proto__

key in quizCollectAnswerScoresではなくObject.hasOwn(quizCollectAnswerScores, key)などを使ったほうがより安全かと思います。

benjuwanbenjuwan

@ghaaj さん

丁寧なご指摘及び解説をありがとうございます!
知らない部分も多くあって大変勉強になりました。

確かに私の型アノテートは、冗長ですし可読性から見てもご指摘いただいたものが一般的だと感じました。

引数(value: string)についても仰る通りで、リファクタリング後の三項演算子を用いた関数のほうが直感的で分かりやすいと思います。

以降のご指摘は(筆者の知識量的に)少し複雑な内容でしたが、
リンクを貼った丁寧な解説のお陰で以下のように理解が進みました。

  • 構造的部分型によって、期待するプロパティを持っていれば追加のプロパティがあっても型チェックを通過してしまう
  • in演算子 では型の絞り込み(Narrowing)が起きないようになっていて、かつ継承元のプロパティも引き継いでしまう。型ガードなどで防ぐ方法はあるものの、今回の事例ではJavaScript__proto__(すべての継承元)のせいで型と実行時の値に不一致が生じる。
  • Object.hasOwn(quizCollectAnswerScores, key)を使用することで、指定したオブジェクトとキーのみチェックするため、 in演算子とは異なり継承関連による懸念が解消される

ご教示いただいた内容は記事にも反映させていただきます。