TypeScriptとの向き合い方
TypeScriptと型
TypeScriptはJavaScriptに静的型を追加した言語です。
逆に言えば本来は不要な型を追加することで、開発を効率化するのがTypeScriptとも言えます。
つまりTypeScriptにおける型は開発を補助するための道具であり、それを目的に使用するべきなのです。
推論の無効化
しかしTypeScriptで書くこと自体が目的化してしまうと、型に振り回され、型エラーを消すことに必死になります。
その際たる例がany
や!
、as
です。
これらはTypeScriptが行う推論を、人間が上書きすることができるものです。
つまりただエラーを消すことが目的化してしまうと、これらが多用され、推論が無効化されてしまいます。
型の正当性
TypeScriptはコンパイルされ、最終的にJavaScriptとして実行されます。
そのため実行時に型が使用されることはありません。
つまり実際の挙動と定義が違っても動作しますし、TypeScriptに間違った推論をさせられるわけです。
こうなると間違った推論に踊らされ、開発効率が落ちる状況にもつながりかねません。
たとえば下記の例において、obj = {a: 1}
ですが、obj.a
を呼び出すとエラーになり、obj.b
があると推論されてしまいます。
const makeObj = (): any => ({ a: 1 });
const obj: { b: number; } = makeObj();
obj.a;
// -> Property 'a' does not exist on type '{ b: number; }'
obj.b;
// エラーにならない
もちろんこれは極端な失敗例ですが、実際のプロダクト上でもこうしたケースは往々にしてあります。
TypeScriptはただ言語選択すれば良いものではなく、使い方次第では開発効率が落ちる要因にもなってしまうのです。
過剰な型定義
また反対に、とにかく型をつけてしまう例もあります。
言語仕様的に型宣言が必要な言語もあるので、そのノリで書いていると手当たり次第に型をつけたくなってしまうかもしれません。
const users: User[] = getUsers();
const userNames: string[] = users.map((user: User): string => user.name)
繰り返しますが、型は実行に必要なものではありません。
開発に必要な最低限さえ指定すれば、それ以上はなくても問題はないのです。
たとえば上記の例は、getUsers
の型が正しく定義されていれば、users
、userNames
、user
などの型はTypeScriptが全て推論してくれます。
const getUsers = (): User[] => []
const users = getUsers();
const userNames = users.map(user => user.name)
もちろん戻り値の保証をしたいケースなど、あえて指定するメリットもあります。
あくまで人間が必要だから定義するもので、「型エラーを消さないといけない」「型を付与しないといけない」と振り回されるものではないということです。
道具としてのTypeScript
では過剰な型定義を避けるとしたら、必要最低限の型定義はどこになるのでしょうか。
それは被依存側です。
被依存側で正しく定義されていれば、それを使う先の推論はTypeScriptが行ってくれるからです。
逆に言えば被依存側で定義されていないと、それを使う側では毎回型を指定する必要が出てきます。
誤った型で指定してしまうかもしれませんし、のちに関数側の挙動が変わってしまったときに実際の挙動と定義が変わってくる危険性があります。
では具体的にどういったときに、これが起こり得るのでしょうか。
型指定するのが困難なケース
1つ目はちゃんと型を定義するのが困難なケースです。
汎用的な処理で引数にany
が混入してしまったり、正しい型定義が困難でそうなってしまいます。
const dig = (obj: any, paths: string[]): any => paths.reduce((path, carry) => carry[path], obj);
const companyName: string | undefined = dig(user, ['company', 'name'])
TypeScriptを使う場合、極力こうした推論が効かない処理を避け、推論しやすい書き方をすることが好まれます。
今はライブラリもTypeScriptフレンドリーかどうかが採用基準になることが多く、どうしても型定義が困難場合はそもそも書き方から見直したほうが良いかもしれません。
外部ライブラリがanyで定義されている
他にはライブラリの型が定義されていなかったり、any
を使用していて、それを使った際にany
が混入してしまうケースです。
たとえばReduxを使っているプロダクトで下記のような例がありました。
const userName = useSelector((state: ({ user: { name: string } })) => state.user.name)
本当にどうしようもないケースもありますが、こうした場合は極力場当たり的に対応するのでなく、根元から型を修正したほうが良いでしょう。
たとえば上記の例だと、DefaultRootState
をoverloadすることで型指定が不要になります。
また、先にも書きましたが極力TypeScriptフレンドリーなライブラリを技術選択することが好まれます。
特に利用者が多いライブラリは型の定義や更新も頻繁になされるため、迷った際はメジャーなライブラリを使用すると良いでしょう。
外部からの入力
もっとも重要なのが、外部からの通信で、特にAPI通信部分です。
APIのパラメータや戻り値は実行時になるまでわからないため、指定した型で動作する保証が最後までとれないからです。
一時的に正しく型定義できても、バージョンアップで変わってしまうこともあるかもしれません。
どうやってここの型の整合成を担保するかがTypeScriptを使用する上で特に重要になってきます。
腐敗防止層を用意する
たとえばreduxを使用している場合、action内で通信処理を書いてしまうケースがあります。
記述箇所が分散していると該当箇所を見つけるのが困難ですし、同じエンドポイントへの通信処理が複数箇所に記載されてしまう恐れがあります。
そうなると修正漏れが発生してしまうかもしれません。
そのため腐敗防止層を用意し、そこで型の差異を吸収します。
そこで内部で使う型に変換すれば、外部の変更の影響は受けづらくなります。
type UserResponse = { user_id: string; user_name?: string }
type User = { id: number; userName: string }
const fetchUser = (id: number): User => fetch(...).then(res => ({ id: +res.user_id, userName: res.user_name || '' }))
実行時に型との整合成を確認する
また実行時に型との整合性を確認できるライブラリも存在します。
たとえばzodやio-tsなどです。
const userSchema = z.object({
id: z.number(),
name: z.string()
});
const user = userSchema.parse(response.data)
これらのライブラリは最大文字数など細かいルールづけも可能で、フォームの入力値のバリデーションにも使われることも多くあります。
TypeScript内の世界を型安全に保つためにも、外部からの入力の確実性をこうしたライブラリで担保することも重要になってきます。
OpenAPIのスキーマからクライアントを自動生成する
OpenAPIのスキーマからTypeScriptのクライアントを自動生成することがで、型の整合成を保つことができます。
スキーマさえあれば良いため柔軟性が高いアプローチな一方で、スキーマの整合成を保つ仕組みも必要になります。
特に、バックエンドのコードとスキーマの整合性が保たれなければ元も子もありません。
ただスキーマを書くだけだとメンテナンスコストも高く、実際の戻り値と乖離してしまうことを避けるのは困難です。
そのためOpenAPIスキーマからバックエンドのコードを自動生成するアプローチと、バックエンドのコードからOpenAPIスキーマを生成するアプローチのどちらかはほぼ必須と言えるでしょう。
Isomorphicなアプローチ
バックエンドもTypeScriptで書かれていれば、コードをバックエンド、フロントエンドで使いまわすことができます。
つまりバックエンドを外部ではなく、内部にしてしまうことができるわけです。
マイクロサービスのように細かく外と内で分けるアプローチも進んでる一方で、blitz-jsのように外部との境界をなくす方向のアプローチも進んでいます。
どちらも一長一短あるので、プロダクトの要件に合わせ適切に技術選択していく必要があるでしょう。