TypeScriptの型定義と実務でありがちな問題コードの対策
はじめに
設計思想によって多少変わりはしますが、TypeScriptを用いた開発では型安全なコードを書くのが理想と考えられます。しかし、実際業務をしていると工数に制限があったり、大人数でそれぞれが作業する兼ね合いで理想のコードではない状態のコードにぶつかる場面がとても多いです。
例えば、既存のJavaScriptを流用するためにany型を使用したが、本来はきちんと型を定義して呼び出し時に安全に使用したい。既存実装が複雑でわからず影響範囲も考えとりあえずnullableにしたが、本当は全部調査して安全に使用したい。といったことはよくありますよね。
本記事ではそんなTypeScript開発者の理想と現実を踏まえ、筆者が所属するThinkings株式会社の実際のReact / TypeScriptを用いた業務の中で出会ったありがちな問題やミスを挙げ、解決方法や回避方法について述べます。
※後述のサンプルコードの中でReact/TypeScriptを用いた例が出てきますが、Reactを知らない方でも問題なく理解できる内容になっています。
こんな人に読んでほしい
- 業務でTypeScriptを使用している方、TypeScriptでの開発にある程度慣れている方
- TypeScriptについて勉強中・調査中であり、一通り理解はできたので実践的な内容が知りたい方
(前置き) 型推論と型アノテーション
型推論は例として、関数の宣言時にその関数の型を明示しない場合が挙げられます。
この場合、returnしている値からその関数の戻り値の型を推論してくれます。(returnしない場合はvoid型になったりもします)
function getUser() = {
return {
name: "Alice",
age: 30
};
};
// 戻り値の型は{
// name: string,
// age: number
//}
型アノテーションは逆に、関数宣言時に型を明記した場合に当てはまります。
関数を利用する際は型推論と変わりありませんが、関数を編集する際に型に違反した戻り値を返そうとすると(あるいは返さないと)エラーとなります。
type User = {
name: string;
age: number;
};
function getUser(): User = {
return {
name: "Alice",
age: 30
};
};
//戻り値の型は「User」
アノテーションを付けた方が開発時は型が堅牢でミスが少なくなります。一方、型推論は記述量が少なく済むなどのメリットがあります。
実務における理想と現実
上記の通り、設計思想によって多少変わるとは思いますがTypeScriptを用いた開発では型安全なコードを書くことでミスが減り、開発者にとって理想的なコードになると言えそうです。
しかし、はじめにの章で述べた通り実際の業務の中では様々な制約の兼ね合いにより、そんな開発者の理想と乖離するコードがあります。
次の章では、そんな実務のあるあるを挙げつつ回避策や解決策について論じていきます。
実務でありがちな問題と対策
関数の戻り値が型推論により不要なユニオン型になる
型推論により、意図しないユニオン型の戻り値が発生することがあります。
特に関数の戻り値の型が型推論で決まっている場合、returnする値は注意する必要があります。
例えば下記のようなコードがあったとしましょう。
function validate(value: string){
if(isSameValue(value)){
return {
validate: false,
message: "同じ名前は使えません"
}
}
return {
validate: true,
message: ""
}
}
この場合、validateの型は以下になります。
{
validate: boolean;
message: string;
}
このコードに、「作成モードの場合は同じ名前のチェックをスキップする」という機能を書き加えたいとします。
その際、以下のように修正しました。
function validate(value: string){
// 追加
if(Mode === "Create"){
return {
validate: true
// 本当はmessage: "" も含みたかった
}
}
if(isSameValue(value)){
return {
validate: false,
message: "同じ名前は使えません"
}
}
return {
validate: true,
message: ""
}
}
この場合、validateの型は以下になります。
{
validate: boolean;
message?: undefined;
} | {
validate: boolean;
message: string;
}
最初はオブジェクトだった戻り値がオブジェクトのユニオン型となってしまいました。これによってvalidate関数の戻り値を使用する個所で、messageがundefinedであることを考慮する必要ができてしまいます。
本来はmessageをundefinedにする必要はなく、コメントの通りmessageを戻り値に加えたかったのですが、型推論の状態だと関数の中ではエラーが発生しないのでこのミスに気付きにくいです。
対策
関数の型をアノテーションで指定してしまえばこのミスは防げます。時間や工数に余裕があるのでしたら、アノテーションは極力つけるべきです。
// 追加
type ValidateResult = {
validate: boolean;
message: string;
}
function validate(value: string): ValidateResult{ // 追記
if(Mode === "Create"){
return {
validate: true
// messageを含まないので、ここでコンパイルエラー
}
}
// 以下略
}
数行程度で終わる簡単な関数に専用のアノテーションをするのは手間かもしれません。また例で挙げたように既存の関数に修正を入れる場合かつ既存の関数が型推論の場合は、アノテーションをする工数が大きくなるかもしれません。しかしアノテーションを付けない場合は単純に戻り値の型に注意を払うか、ユニットテスト等を用いるといった方法になります。
単純に注意する場合は例で挙げた事象が頻発するでしょう。アノテーションさえあれば済む確認をわざわざユニットテストの実装まで見に行く必要があるのもあまり効率的ではなさそうです。
ですので特に今後機能が増えることが見込まれる場合や、複数人で開発を進めることがわかっている場合などはアノテーションをうまく活用したいところです。
Props用のインターフェースを無理に共通利用して情報が追跡できなくなる
この章ではReact/TypeScriptの例をもとに問題を挙げますが、Reactがわからなくても読める内容です。
コンポーネント(ざっくり関数のこと。)に渡すProps用のインターフェースを無理に共通化すると、改修が重なった際に関係がとても複雑になる場合があります。特に大人数で開発する場合、影響範囲や作業量の観点から「とりあえずnullableでプロパティ追加しよう」とすることが多いです。
例として、以下のコードを考えてみます。
- ProductSummary, ProductDetailというコンポーネントがある
- 二つのコンポーネントはItemというインターフェースをPropsとして受け取る
- 現状、二つのコンポーネントではどちらも商品名と商品価格を用いた処理を実装している
- 今回、ProductDetailにだけ商品説明の文章を表示する改修を行う
interface Item {
itemName: string;
itemPrice: number;
itemDescription?: string | undefined // 新機能として追加
}
function ProductSummary(props: Item) {
// itemName, itemPriceを使う処理
}
function ProductDetail(props: Item) {
// itemName, itemPriceを使う処理
// itemDescriptionを使う処理 ←新機能として追加
}
function MainPage(itemName, itemPrice) {
return (
<ProductSummary props={(itemName, itemPrice)} /> // SubPageでも使用されていて安易にitemDescriptionを必須にできない
<ProductDetail props={(itemName, itemPrice, itemDescription)} />
)
}
function SubPage(itemName, itemPrice) {
return <ProductSummary props={(itemName, itemPrice)} />;
}
Item.itemDescription
はProductSummaryでは不要ですがProductDetailでは必要です。従来の実装でpropsの型を共通していたのをそのまま、呼び出し個所に必要な情報がない都合でnullableの型でItemへ追記をしました。
これを繰り返すと、不要な情報がコンポーネントへ渡ってしまったり、必要な情報が型としてはnullableになっているため呼び出し個所で忘れられ、実行時には渡ってこない。といった問題が発生します。
この問題の発展型として、一つの型が親と子のコンポーネント両方に適用されているケースが混ざると、さらに複雑化します。nullableで渡されている情報が、どのコンポーネントまで到達しているのかの調査が非常に難しくなるからです。
次の例で考えてみます。
- ProductSummaryの子コンポーネントがProductDetail, さらにその子コンポーネントがProductPreviewとなっている
- すべてのコンポーネントはItem型をpropsとして受け取るが、必要な情報がまちまち
interface Item {
itemName: string;
itemPrice: number;
itemDescription?: string | undefined;
itemImage?: Image | undefined; // 新機能として追加
}
function ProductSummary(props: Item) {
// itemName, itemPriceを使う処理
ProductDetail({
itemName: "豚肉",
itemPrice: 100,
itemDescription: "豚のお肉",
})
}
function ProductDetail(props: Item) {
// itemName, itemPrice, itemDescriptionを使う処理
ProductPreview(props)
}
function ProductPreview(props: Item) {
// itemName, itemPrice, itemDescriptionを使う処理
// itemImageを使う処理 ←新機能として追加
}
ProductPreviewに着目しますと、Item.itemImage
の情報を使いたいのですが、実行するとundefinedになっているようです。nullableなのでコンパイルエラーは出ないので、実装を追っていく必要があります。
ProductDetailでProductPreviewを呼び出している個所がありましたが、ここでは問題なさそうです。つぎにProductSummaryにProductDetailの呼び出しがありますが、ここで渡しているpropsにitemImageの情報がないではありませんか。
という手順を踏まないと、Item.itemImage
の値が一体どこで変わっているのかを追跡できません。今回の例ですと呼び出しを二回たどれば発見できましたが、大人数で大きなプロジェクトを開発している場合はこの事象が入り乱れ、原因特定にとても苦労します。
これにより、調査や修正範囲が大きくなり、結果問題が放置されることが多くなります。
対策
今回の問題は、それぞれのコンポーネントへ渡す専用の型を用意することで解決できます。そうすることで不必要なnullableのプロパティを抑制し、呼び出し時に本当に必要な情報を渡せるようにできるためです。
interface Item {
// 同じなので省略
}
// 新たに作成
interface ProductDetailItem {
itemName: string;
itemPrice: number;
itemDescription: string;
itemImage: Image;
}
function ProductSummary(props: Item) {
// itemName, itemPriceを使う処理
ProductDetail({
itemName: "豚肉",
itemPrice: 100,
itemDescription: "豚のお肉",
// ProductDetailItem.itemImageが必要なので、ここでエラー
})
}
function ProductDetail(props: ProductDetailItem) {
// itemName, itemPrice, itemDescriptionを使う処理
ProductPreview(props)
}
// 以下略
ProductDetail専用のインターフェースを用意したことで、itemDescriptionとitemImageをnon nullable(必須)にできました。これにより呼び出し時に必要な情報を渡さないとエラーとなります。
今後新たに関数が新設され、また新たなプロパティが必要になった際も同様の対応をすることで、情報の過不足の調査コストを抑えた開発が可能になります。
おわりに
業務をしていると、開発者的には保守の観点ではコストをかけてでも質の高いコードを書きたいけど納期や工数の制限、既存仕様の関係などでこうせざるを得ない。という場面はとても多いと思います。
今回挙げた例は筆者が遭遇したものの中で、これだけは守りたいという筆者の線引きのようなものです。みなさんもこの記事を参考に、「自分の開発において理想的なTypeScriptの書き方」を研究していただきたいです。
最後まで読んでいただきありがとうございました。
Discussion
これなら 次の型定義にしておけばよかったのでは……?感あります。
ご高覧&コメントありがとうございます!
ご指摘いただいている通りですね、あまりよくないサンプルコードでした。
実際に出会ったコードは、下記のように戻り値に数多くのオブジェクトが期待されていて、それが型推論の関数で出来ているものでした。
その関数に、returnを伴うような変更を加えるときに期待されているreturnを正確に理解するのがつらいよね、ということを述べたかったです。
戻り値を推論させて呼び側の引数渡しで型のミスマッチを検証させるか、戻り値の型も常に明示して早期に誤りを検知するかは、実際は宗派によりそうな気がしています。
弊社では書き手に任せて「どちらでもOK」というスタンスを取っていますが、僕はTypeScriptは戻り値の型を推論させる方が相性がいい気がしているので、必ずチェックを行いたい場合以外はlspで戻り値の型を確認して、終わりにしています👀
ご高覧&コメントありがとうございます!
筆者の環境は「戻り値の型も常に明示して早期に誤りを検知する」宗派が多いようです。
仰る通りで宗派やその時の設計思想によってどちらが良いか、統一すべきかそうでないかは変わるのだなと考えさせられました。