🧩

TypeScriptで似たような型を上手く使い回すために知っておいたほうが良いかもしれない2つのこと

2021/12/03に公開約4,300字2件のコメント

TypeScriptの型付けは非常に強力と言われる一方で、型の定義の仕方は開発者に委ねられてしまいます。
どれだけ厳格に型を定義していようがAnyを濫用していようが、コンパイルが通っているのであればそれはTypeScriptです。
そこでなるべく良い型付けをするために知っておいたほうが良さそうなことを挙げてみます。

TypeScriptにおいて型はコードに寄り添ったドキュメントであり、良い型付けを行うことで未然に事故を防いだり、誤って型が使用されることを防げたりするかもしれません。
※個人の見解を含みます。

全体を通して例で共通的に使うコード

types.ts
type UserType = 'Admin' | 'User';
type Authority = 'ROLE_A' | 'ROLE_B' | 'ROLE_C';

type User = {
  id: number;
  name: string;
  type: UserType;
  authorities?: Authority[];
}

例は全体的にシンプルにしているので、ピンとこないことも多いかもしれませんが、もっと複雑なコードになった際には役立つこともあると思っています。

1.一部のプロパティを参照するためだけにPartialを使わない

Partialを使うとその型に存在するすべてのプロパティをOptionalにしてくれます。
一見便利ではあるのですが、使い所は考えたほうが良さそうです。

以下はあまり良くない例です。

notGood.ts
// Userの中のidとnameだけを返す関数
const getIdAndName = (user: User): Partial<User> => {
  return { id: user.id, name: user.name }
}

Userの型定義を見ると本来idやnameはOptionalではないのですが、Partialを通すことによって、コンパイラにはidやnameもOptionalなプロパティに見えてしまいます。
また、当然コンパイラからすると以下のようにidとname以外の不必要なプロパティが参照できます。

const idName = getIdAndName({ id: 1, name: 'user', type: 'Admin'});
idName.type // undefined 本当はidとnameのみ参照したい

こういった場合はPickOmit等を使ってあげるのが良いです。
Pickは指定したプロパティのみを使って新たな型を作り、Omitは指定したプロパティを除いて新たな型を作ります。

good.ts
const getIdAndName = (user: User): Pick<User, 'id'|'name'> => {
   return { id: user.id, name: user.name }
}
// 命名と微妙に合っていないが以下も場合によってはありかも
// const getIdAndName = (user: User): Omit<User, 'type'|'authorities'> => {
//   return { id: user.id, name: user.name }
// }

// 実行例
const idName = getIdAndName({ id: 1, name: 'user', type: 'Admin'});
idName.type // コンパイルエラー

上記の例は単純なものですが、個人的によく見たりするのは、ReactやVueのコンポーネントに不必要なプロパティを含んだオブジェクトを渡したりしているものです。
(IDとNameを表示するだけのコンポーネントにUserやPartial<User>を渡しているなど)

2.複数のユースケースが考えられる型は無理に使い回さない

一度定義した型は色んな所で使い回せますが、汎用的な型にし過ぎて使い回すと辛くなるときがあります。
以下のような例を考えます。

notGood.ts
// getUserはauthoritiesがセットされていないUserを返す
const getUser = (): User  => {
  return { id: 1, name: 'user1', type: 'Admin' }
}
// setAuthrities は authoritiesをセットしたUserを返す
const setAuthorities = (user: User): User => {
  return { ...user, authorities: ['ROLE_A', 'ROLE_B']}
} 

// authoritiesなしUser
const user = getUser();
// authoritiesありUser
const userWithAuthorities = setAuthorities(user);

上記のような状態で以下の関数の型定義を見てみましょう。

// authoritiesなしUserに対して何かをしたい関数
type doSomethingForUser = (user: User) => void
// authoritiesありUserに対して何かをしたい関数
type doSomethingForUserWithAuthorities = (user: User) => void

2つの関数の型定義を見てみましたが、(命名で表現しようとはしているものの)型レベルでは引数に受け取るUser型の違いがわかりません。
(実装時にはundefinedかどうかをチェックする必要が出てきます)

こういったときにはそのまま使い回さず別の型を用意してあげるのが良いかもしれません。

types.ts
type UserType = 'Admin' | 'User';
type Authority = 'ROLE_A' | 'ROLE_B' | 'ROLE_C';

type User = {
  id: number;
  name: string;
  type: UserType;
}
// authoritiesを分離
type UserAdditional = {
  authorities: Authority[];
}
// authoritiesがセットされた状態のUser
type UserWithAdditional = User & UserAdditional;

以下のように使用します。

good.ts
const getUser = (): User  => {
  return { id: 1, name: 'user1', type: 'Admin' }
}
// 戻り値をUserWithAdditional型にしている
const setAuthorities = (user: User): UserWithAdditional => {
  return { ...user, authorities: ['ROLE_A', 'ROLE_B']}
} 

const user = getUser();
const userWithAuthorities = setAuthorities(user);

// Userを使う関数の型
// authoritiesなしUserに対して何かをしたい関数
type doSomethingForUser = (user: User) => void
// authoritiesありUserに対して何かをしたい関数 引数がUserWithAdditional型になっている
type doSomethingForUserWithAuthorities = (user: UserWithAdditional) => void

こうすることにより型レベルで意図が伝わりやすくなったのではないでしょうか?

個人的な経験だとAPIを複数回呼んでデータを構成するときなど、全て同じ型で定義してしまうと後から仕様を把握するのに苦労してしまうことがありました。
(上の例に寄り添うとUserを取得するAPIとAuthoritiesを取得するAPIが別れている場合など)

また、別のアプローチとしてはRequiredを使う方法もあるかもしれません。
が、こちらは全てのプロパティが必須となるので、authorities以外にもOptionalなプロパティが増えた場合には考慮が必要かもしれません。

type User = {
  id: number;
  name: string;
  type: UserType;
  authorities?: Auhotiry[];
}
// Userの全てのプロパティを必須にする
type doSomethingForUserWithAuthorities = (user: Required<User>) => void

最後に

TypeScriptの型について色々書いてきました。
どこまで厳密に定義するのが最適なのかは場合によりけりかとは思いますが、その型がどういうユースケースのために定義された型なのかは常に意識しておいたほうが良いと思います。

この記事ではなるべく具体的な例にそって色々と紹介しましたが、TypeScriptには他にも便利なUtility Typesが存在するので、ぜひ参考ページの方も参照して使い道を探っていただければと思います。

参考

サバイバルTypeScript ユーティリティ型 (utility type)
【TypeScript】Utility Typesをまとめて理解する

GitHubで編集を提案

Discussion

最近ちょうど同じこと考えてて、同士を見つけて心強いです。
ただ2については型の名前付けが困難(特にOptionalが多すぎて組み合わせ量が爆発する場合)になりそうなことがネックなんですよね……。

コメントありがとうございます!

ただ2については型の名前付けが困難(特にOptionalが多すぎて組み合わせ量が爆発する場合)になりそうなことがネックなんですよね……。

これは確かにありますよね。
この手の話はどうしても程度の問題が出てくると思うので
最終的には状況に応じていい感じにとしか言えないところはありますね。

あくまでその関数、処理が引数のどのプロパティに関心を持っているのかを
なるべくはっきりさせるようにしようという努力目標的な意図で書かせていただきました。

ログインするとコメントできます