かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript オブジェクトのデフォルト値を Array.reduce で設定しようとしたら型指定でハマった

APIからあるオブジェクトが渡され、フロントでデータが無ければデフォルト値を付けるような処理を TypeScript で書いていてハマったのでメモ。
※ フロントは React です

API から渡されるデータ

InitUnitData = {
  first: {
    id: 1, name: '春風 わかば', type: 'cute',
  },
  second: {
    id: 2, name: '姫石 らき'
  }
}

type が無ければデフォルト値を付けたい

Array.reduce でオブジェクトをフォーマットする

JavaScript で書くとこんな感じ。

const DefaultType = 'cute';

function Unit({ InitUnitData }) {
  return <UnitContainer {...formatUnitData(InitUnitData)} />
}

function formatUnitData(unitData) {
  return Object.keys(unitData).reduce((acc, key) => {
    const { type, ...data } = unitData[key];
    return {
      ...acc,
      [key]: {
        ...data,
        type: type || DefaultType,
      }
    };
  }, {});
}

Array.reduce の初期値を空オブジェクト {} にして、それにデフォルト値を設定したデータを追加していけばOKって感じ。至ってフツーの書き方かな〜と思っています。

この処理を TypeScript で書こうとしてハマっていました。

TypeScript で Array.reduce でオブジェクトのデフォルト値を設定する

APIから渡されるデータの型と、コンポーネントで使用するデータの型

APIから渡されるデータは type があったりなかったりするので type?: string ですが、コンポーネントで使用する型は type が設定されている前提なので type: string になります。

フォーマットされたデータを扱うコンポーネント

// UnitContainer.tsx
export type IdolDataType = {
  id: number;
  name: string;
  type: string;
};

export type UnitDataType = {
  first: IdolDataType;
  second: IdolDataType;
};

type UnitContainerProps = {
  unitData: UnitDataType
};

export default function UnitContainer({ unitData }: UnitContainerProps) {...}

APIからデータを受け取って整形してコンポーネントに流し込む箇所

// Unit.tsx
import UnitContainer, {
  IdolDataType,
  UnitDataType,
} from './UnitContainer';

type InitIdolDataType = Omit<IdolDataType, 'type'> & {
  type?: string;
};

type InitUnitDataType = {
  first: InitIdolDataType;
  second: InitIdolDataType;
}

const DefaultType = 'cute';

type UnitProps = {
  initUnitData: InitUnitDataType;
};

export default function Unit({ initUnitData }: UnitProps) {
  return <UnitContainer ...formatUnitData(initUnitData) />;
}

つまりデータを整形する formatUnitData 関数は、 InitUnitDataType型を受取り UnitDataType型を返す必要があります。

Array.reduce でオブジェクトを整形する部分の型指定

JavaScript 版ではこうでした

function formatUnitData( initUnitData ) {
  return Object.keys(unitData).reduce((acc, key) => {
    const { type, ...data } = initUnitData[key];
    return {
      ...acc,
      [key]: {
        ...data,
        type: type || DefaultType,
      }
    };
  }, {});
}

関数の引数と返る値に型を付けると function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType こうなります。

1. return されるオブジェクトの型が不明

Array.reduce は第二引数 (acc の初期値) が戻り値の型推論されるので、 初期値に空オブジェクト {} を渡すと {} が返されると推論されてしまうので、関数の返すべき型と合ってないとなる。as で初期値の型を指定する

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return Object.keys(unitData).reduce((acc, key) => {
    const { type, ...data } = initUnitData[key];
    return {
      ...acc,
      [key]: {
        ...data,
        type: type || DefaultType,
      }
    };
  }, {} as UnitDataType);
}
2. Array.reduce 内の関数に渡される keyany と判断されるので initUnitData[key] が何か推論できない

initUnitData[key] の部分で Element implicitly has as 'any' type because ... が出されます。
これは TypeScript はオブジェクトのプロパティに変数を使ったブランケット記法 (Object[key]) でアクセスすると、コンパイラは何型が帰ってくるか推定できずエラーとなってしまうようです。

keyof を使って Object.keys(unitData)InitUnitDataType のキーの配列で、key には InitUnitDataType のキーのいずれかも文字列しか入らない事を明示する

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return (Object.keys(unitData) as Array<keyof InitUnitDataType>)
    .reduce((acc, key) => {
      const { type, ...data } = unitData[key];
      return {
        ...acc,
        [key]: {
          ...data,
          type: type || DefaultType,
        }
      };
    },
    {} as UnitDataType
  );
}

これで key'first' | 'second' になるので unitData[key] が何型を持つかわからないというエラーは解消されます。

3. Array.reduce 内の acc は初回ループ時に実態と型が合っていない状態になっている。

2.の段階で基本的にコンパイラのエラーは消えているのですが、Array.reduce で渡される acc 空オブジェクトで、acc.first, acc.second は型上は IdolDataType と推論されるけど、実際のデータでは undefind と乖離しているので Partial<UnitDataType> として undefind の含むようにした方が良いと教えてもらいました。

Partial
型 T のすべてのプロパティを省略可能(つまり | undefined )にした新しい型を返す Mapped Type です。

interface Foo {
  bar: number
  baz: boolean
}
type PartialFoo = Partial
// PartialFoo {
//   bar?: number
//   baz?: boolean
// }
cf. TypeScript特有の組み込み型関数 #Partial

Array.reduce の初期値を {} as Partial<UnitDataType> とすれば acc{first?: IdolDataType, second?: IdolDataType} という型として扱われるようになるっぽい。

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return (Object.keys(unitData) as Array<keyof InitUnitDataType>)
    .reduce((acc, key) => {
      const { type, ...data } = unitData[key];
      return {
        ...acc,
        [key]: {
          ...data,
          type: type || DefaultType,
        }
      };
    },
    {} as Partial<UnitDataType>
  );
}

こうすると return されるオブジェクトが {} の可能性を含んでしまってコンパイラにエラーを言われるので return されるオブジェクトは UnitDataType 型だと明示します

function formatUnitData( initUnitData: InitUnitDataType ): UnitDataType {
  return (Object.keys(unitData) as Array<keyof InitUnitDataType>)
    .reduce((acc, key) => {
      const { type, ...data } = unitData[key];
      return {
        ...acc,
        [key]: {
          ...data,
          type: type || DefaultType,
        }
      };
    },
    {} as Partial<UnitDataType>
  ) as UnitDataType;
}

これで、TSlint を通るようになりました。

所感

個人的にはただオブジェクトにプロパティが無ければ初期値を付けたかっただけなのに、コンパイラが理解できるように厳密に型を保証しようとすると冗長になってしまうな〜という印象でした。

プロパティを | undefind に変換できる Partial や部分的に除く Omit など TypeScript 特有の関数知らないものが多く、JavaScript で考えて TypeScript に変換しようとしてしまうと今回のようにハマってしまうから、最初からコンパイラの頭で TypeScript で考えれるようにならないと、ロジックを考える時間より、TSlint が通るようにする作業のほうが多く時間がかかってしまって健全じゃないから、ちゃんと TypeScript 学習しておかないと今後キツイな〜と実感しました。

ガンバロ…


[参考]

chaika.hatenablog.com

リベリオン-反逆者- [Blu-ray]

リベリオン-反逆者- [Blu-ray]

  • 発売日: 2009/06/03
  • メディア: Blu-ray
ガンカタ…