💬

TypeScript、ネストする型定義とFE/BEでの使い方

2024/08/15に公開

前提

  • 研修を終えて配属一週間目のひよこです
    • 研修で触れた言語やフレームワーク
      • HTML, CSS
      • JavaScript
      • React
      • TypeScript
      • python
      • Java
      • kotlin
      • SpringBoot
  • 諸々触れることはできていても理解は甘々なので、自分でwebアプリを作ってみてます
  • TODO管理アプリのフロントとバックを作ります
    • 使用する言語など
      • フロント
        • TypeScript
        • React
      • バック
        • TypeScript
        • Firebase Function
    • プロダクトのコンセプト
      • TODO粒度での振り返りを行いやすくすることで、成長のPDCAを加速させる

この記事の目的

自分の定着目的で、現状の理解をメモ程度にアウトプットします。
人に読ませるつもりでは書けてないです。

やりたいこと

  • TODO管理アプリにて、タスクカテゴリ配下に複数のタスクを登録(POST)できるようにしたい
    • それにまつわる諸々をアウトプットします

やったこと(着手順)

型定義

  • まずはTaskGroupとTaskItemそれぞれの完全版の型を定義します
  • 今回作るPOSTの場合、IDをリクエストに含められない(firebaseの自動採番)ので、Request用の型も別途用意する必要があります
  • 構成として、フロントからもバックからも参照されるので、ルート直下にcommonというフォルダを作り、その中のschemaというフォルダに書きました
    • フロントで共通の資材(headerなど)は、フロントのコードが集まってるsrc直下にcommonというフォルダを作って書いていくつもりです。

TaskItem

タスクにステータスや優先度などのフラグをつけたり、振り返りや完了日をつけたりできるようにしたい。
振り返りを別DBで持つか迷いましたが、なんかIDで紐付けるのが大変になりそうなのでTaskItemに全部持たせることにしました。

export type TaskItem =  {
    taskItemId: string;
    taskItemCreateAt: Date;
    taskItemDeadline: Date;
    taskItemTitle: string;
    taskItemEstimateTimes?: number | null;
    taskItemMemo?: string | null;
    taskItemFlag?: "未着手" | "進行中" | "ペンド" | null;
    taskItemPriority?: "未判断" | "優先" | "非優先" | null;
    taskItemDoneFlag: boolean;
    taskItemDoneDate?: Date | null;
    taskItemReflectionGood?: string[] | null;
    taskItemReflectionMore?: string[] | null;
}

この型を参照してFEでもBEでも書くことになります。
型エラーが起きたときは大体ここを見返します。
なお、

taskItemFlag?: "未着手" | "進行中" | "ペンド" | null;

select要素で、あらかじめ定義したどれかのvalueになっていて欲しい場合は上記のように書けるみたいです。
「|」って使い道たくさんありますね。
また、入力しなくてもいいのでnullにしました。

TaskGroup

import { TaskItem } from "./TaskItem";

export type TaskGroup =  {
    taskGroupId: string;
    taskGroupName: string; //案件名など
    taskGroupItems?: TaskItem[] //案件に紐づく複数タスクの配列
}

taskGroupに複数のタスクを持たせたいので、先ほど定義したTaskItemの配列型で定義しました。

RequestTaskItem

POST時に使う型なので、完了日などPOSTで使わないものは除きました
これが吉と出るかはまだわかっていませんが、PUTの時はまた別の型を定義しようと思ってます

export type RequestTaskItem =  {
    taskItemDeadline?: Date | null;
    taskItemTitle: string;
    taskItemEstimateTimes?: number | null;
    taskItemMemo?: string | null;
    taskItemFlag?: "未着手" | "進行中" | "ペンド";
    taskItemPriority?: "未判断" | "優先" | "非優先";
  }

またIDや作成日もPOSTで登録されるものですが、フロントからのリクエストに含まれるものではないので必然的に除いています。

RequestTaskGroup

POST時に使う型なので、完了日などPOSTで使わないものは除きました
これが吉と出るかはまだわかっていませんが、PUTの時はまた別の型を定義しようと思ってます

import { TaskItem } from "./TaskItem";

export type TaskGroup =  {
    taskGroupId: string;
    taskGroupName: string;
    taskGroupItems?: TaskItem[]
}

そういえば、export constで書いた関数をインポートする場合は、{}で囲まないと「既定のエクスポートがありません」というエラーになります
パス指定があってるのに上記エラーが起きる場合は確認しましょう。

Firebase Function

  • functions/src/index.tsに、一旦全部まとめて記載

設定的な部分

エラーに苦しむ自分にAIが差し伸べた以下2つは、呪文のように貼りました。

const corsHandler = cors({ origin: true });
admin.initializeApp();

CORSエラーが出ている時は上記1つ目が大事。
でもadminのやつが本当に重要で、これを書いている位置がどこかの関数内だった時はデプロイエラーがでまくりました。
admin.initializeApp();はファイルの冒頭で書くべし。

POSTのコード

  • 役割
    • firebaseにTaskGroupと、その配下のTaskItem[]をPOSTする
  • やらなきゃいけないこと
    • CORS処理でアクセスの許可
    • TaskItemは複数登録可能なので、mapにする
    • firebaseにpostする
      • それぞれのIDをFirebaseのIDからとってくる
    • レスポンスやエラーをreturnする
  • 実際のコード
exports.createTaskGroup = myFunction.onRequest((request, response) =>{
  corsHandler(request, response, async () => {
    try {
      const requestNewTaskGroup = request.body;
      const newTaskGroupRef = admin.firestore().collection('taskGroup').doc();
      const taskGroupId = newTaskGroupRef.id;

      const taskGroupItems = requestNewTaskGroup.taskGroupItems.map((item: TaskItem) => {
        const newTaskItemRef = admin.firestore().collection('taskItem').doc();
        const taskItemId = newTaskItemRef.id;
        const taskItemCreateAt = new Date();

        return {
          taskItemId,
          taskItemCreateAt,
          taskItemDeadline: new Date(item.taskItemDeadline),
          taskItemTitle: item.taskItemTitle,
          taskItemEstimateTimes: item.taskItemEstimateTimes,
          taskItemMemo: item.taskItemMemo,
          taskItemFlag: item.taskItemFlag,
          taskItemPriority: item.taskItemPriority,
          taskItemDoneFlag: false,
          taskItemDoneDate: null,
          taskItemReflectionGood: [],
          taskItemReflectionMore: []
        };
      });

      const newTaskGroup: TaskGroup = {
        taskGroupId,
        taskGroupName: requestNewTaskGroup.taskGroupName,
        taskGroupItems: taskGroupItems,
      };
      await admin.firestore().collection('taskGroup').doc(newTaskGroup.taskGroupId).set(newTaskGroup);
      return response.status(200).json(newTaskGroup);
    } catch (error) {
      console.error('functionのcreateTaskGroupでキャッチしたエラー', error);
      return response.status(500).json({error: 'Internal Server Error'});
    }
  })
});
  • exportsはFirebaseが提供している関数で、とりあえずこういう書き方にするとFunctionに登録できる
  • onRequestrequestresponseもFirebaseが提供している関数で、リクエストの内容をオブジェクトとして持ったりできるイメージ
    • アロー関数にしてるからrequestとかを引数として扱えるようになってるというイメージ
  • corsHandlerは、リクエストが許可されているかをチェックするミドルウェアらしい
    • requestresponseを引数としてして受け取りつつ、CORSでのチェックが完了したらasync内の処理を実行する感じ
    • thisとかないからアローにする意味はそんなにないけど書きやすいからアロー
  • try内に処理を書いていく
    • フロントが送った内容をrequest.bodyとして取得し、requestNewTaskGroupという関数に詰め込む
    • firebaseのDBに新しく場所(collectiondoc)を確保した処理の結果をnewTaskGroupRefとして定義
    • newTaskGroupRefが持っているidをtaskGroupIdに代入する
  • taskGroup配下に複数入力されるかもしれない各タスクをmap化したtaskGroupItemsを作る
    • TaskGroupの型でtaskGroupItemsを定義していたので、request.bodyにはtaskGroupItemsという配列があるため、一旦その配列を取ってくる
    • そのtaskGroupItemsをmap化し、配列の一つ一つをitemという変数に代入。下ごしらえ完了。
    • taskItemのidをfirebaseから取ってくるために、taskItemというcollectionとdocをnewTaskItemRefとして作成
    • newTaskItemRefのidをtaskItemIdとして定義
    • 別途タスクの作成日を登録したいので、taskItemCreateAtとしてnew Date()を記載
      • ()に何も指定しないとタイムスタンプが取れるっぽい
    • return内に、TaskItemとして持たせたいカラムを全部記載
      • RequestTaskItemと迷ったけど、POSTでDB作りたいので一旦null含めて全カラムを書いた
  • そうして得られたtaskGroupItemsの配列を、POSTしたいnewTaskGroupに詰め込む
    • newTaskGroupも全カラムを持ったTaskGroup型
    • TaskGroupIdは上の方で取ってきてる
    • TaskGroupNameはフロントで入力された内容
    • taskGroupItemsとして、さっき作ったtaskGroupItemsを入れる
  • firebaseにPOSTする
    • collectionとしてさっき作ったtaskGroupを指定
    • どのdocにPOSTするか、taskGroupIdで指定
      • ちなみにcollectionがDBで、docがレコードというイメージ
    • set内にPOSTしたい内容を書くとPOSTできるので、set(newTaskGroup)と書く
  • 正常に実行できた場合のresponseをreturnする
    • status(200)みたいに200にするのはHTTPの慣習的な感じで、別に200以外でも動くけど200にするもの
    • json形式でnewTaskGroupを返すのも忘れずに。
  • catch処理を書く
    • まあerrorを出力する感じ
    • どの段階でerrorが起きたかわからないと大変だから日本語で詳しめに書いてみてるけど、効力があるかはまだ感じられていない。errorをthrowするapiをFEに書くからあんま意味ない気がしてる。

フロントのコード

  • 役割
    • POSTするカテゴリ名やタスク詳細を入力する
    • その値をapiに送る
  • やりたいこと
    • 「+」ボタンを押すとタスク入力欄が増えて、いくつも入力できるようにしたい
import React, { useState } from "react";
import { RequestTaskGroup } from "../../../../common/schema/RequestTaskGroup";
import { RequestTaskItem } from "../../../../common/schema/RequestTaskItem";
import { createTaskGroup } from "./api";

export const CreateTask: React.FC = () => {
    const [taskGroupName, setTaskGroupName] = useState<string> ('');
    const [taskItems, setTaskItems] = useState<RequestTaskItem[]>([]);

    const handleAddTaskItem = () => {
        setTaskItems([...taskItems, {
            taskItemTitle: '',
            taskItemDeadline: null,
            taskItemEstimateTimes: null,
            taskItemMemo: null,
            taskItemFlag: '未着手',
            taskItemPriority: '未判断',
        }]);
    };

    //keyofが存在しない値の可能性を排除するために以下で型指定
    const handleTaskItemChange = <K extends keyof RequestTaskItem>(index: number, field: K, value: RequestTaskItem[K])=> {
        const newTaskItems = [...taskItems];//taskItems(RequestTaskItem[]型)の配列としてnewTaskItemsを宣言
        newTaskItems[index][field] = value;//newTaskItemsのindex番目のfieldキーに引数のvalueを代入
        setTaskItems(newTaskItems);
    };

    const handleCreateTaskGroup = async () => {
        const newTaskGroup: RequestTaskGroup = {
            taskGroupName,
            taskGroupItems: taskItems,
        };

        try {
            const createdTaskGroup = await createTaskGroup(newTaskGroup);  // createTaskGroup関数はAPI呼び出しを行う
            console.log('Task Group created:', createdTaskGroup);
        } catch (error) {
            console.error('Failed to create task group:', error);
        }
    };

    return (
        <div>
            <div>
                <input
                    type="text"
                    value={taskGroupName}
                    onChange={(e) => setTaskGroupName(e.target.value)}
                    placeholder="タスクグループ名を入力"
                />
            </div>
            {taskItems.map((taskItem, index) => (
                <div key={index}>
                    <input
                        type="text"
                        value={taskItem.taskItemTitle}
                        onChange={(e) => handleTaskItemChange(index, 'taskItemTitle', e.target.value)}
                        placeholder="タスクタイトルを入力"
                    />
                    <input
                        type="number"
                        value={taskItem.taskItemEstimateTimes !== null ? taskItem.taskItemEstimateTimes : ''}
                        onChange={(e) => handleTaskItemChange(index, 'taskItemEstimateTimes', e.target.value ? Number(e.target.value) : null)}
                        placeholder="予想分数を入力"
                    />
                    <input
                        type="date"
                        value={taskItem.taskItemDeadline ? taskItem.taskItemDeadline.toISOString().substr(0, 10) : ''}
                        onChange={(e) => handleTaskItemChange(index, 'taskItemDeadline', e.target.value ? new Date(e.target.value) : null)}
                    />
                    <input
                        type="text"
                        value={taskItem.taskItemMemo !== null ? taskItem.taskItemMemo : ''}
                        onChange={(e) => handleTaskItemChange(index, 'taskItemMemo', e.target.value)}
                        placeholder="メモを入力"
                    />
                    <select value={taskItem.taskItemFlag || '未着手'} 
                        onChange={(e) => handleTaskItemChange(index, 'taskItemFlag', e.target.value as "未着手" | "進行中" | "ペンド")}>
                        <option value="未着手">未着手</option>
                        <option value="進行中">進行中</option>
                        <option value="ペンド">ペンド</option>
                    </select>
                    <select value={taskItem.taskItemPriority || '未判断'} 
                        onChange={(e) => handleTaskItemChange(index, 'taskItemPriority', e.target.value as "未判断" | "優先" | "非優先")}>
                        <option value="未判断">未判断</option>
                        <option value="優先">優先</option>
                        <option value="非優先">非優先</option>
                    </select>
                </div>
            ))}
            <button onClick={handleAddTaskItem}>+ タスクを追加</button>
            <button onClick={handleCreateTaskGroup}>タスクグループを登録</button>
        </div>
    );
}

特筆したいこと

  • タスクを簡単に複数入力できるようにする
    • ボタンのonClickでhandleAddTaskItemが起動
      • taskItemsを...で配列として使えるようにする
        • teskItemsはuseStateで初期値[]の配列RequestTaskItem[]型として定義しておく
      • 起動するとuseStateに初期値が入力されるようにする
    • それぞれのinput要素でhandleTaskItemChangeを起動させる
      • taskItemsの配列をmapとして表示していて、そのmapの何番目かをreturn内でindexとして持てる。この「何個か存在するかわからない個数を管理する」ためのmapとindexは結構使いそうな気がするので覚える。
        • indexで区別しないと、一つのinputに入力した内容が他のタスクにも反映されちゃったりする
        • handleTaskItemChangeの第一引数にindexを、第二引数にその変更した値のkey名を、第三引数に入力されたvalueを送る
        • このkey名が、一応型定義されていないkey名も入力できるため型エラーが出る
        • よってkeyがRequestTaskItemの値だと明示して使う
  • 型エラー
    • handleTaskItemChangeとかのselect要素が、型定義外の値を入力されても検知できないようになってしまう
      • e.target.value as "未着手" | "進行中" | "ペンド"みたいに記載することで防げる
      • 多分もっといい書き方ある気がする
  • mapの使い方改めて
    • taskItemsが繰り返し表示したい配列要素
    • taskItems.map((taskItem, index) => (みたいにして、配列の一つ一つの要素をtaskItemとして持ち、それがmap内の何番目かをindexとして持つ
    • 繰り返し表示したい部分を<div key={index}></div>として囲う
      • indexで区別する
    • 表示要素を追加する
      • handleAddTaskItemが呼び出されるとtaskItemsの中身が追加される
      • taskItemsがmapとして繰り返し表示されるので、中身が追加されたらinput要素もちゃんと増える
  • apiにリクエストを送信
    • await createTaskGroup(newTaskGroup)として、createTaskGroupというAPIを呼び出す
    • ここで引き渡すnewTaskGrouphandleCreateTaskGroup内で作成
    • この時のリクエスト内容はIDとかがないので、別で定義しているRequestTaskGroup型で定義

フロントとバックの繋ぎこみ(api)

  • 役割
    • フロント側で受け取った値をバックでapiを定義した関数に送る
    • どのapiを起動するかなどを書く
import axios from "axios";
import { RequestTaskGroup } from "../../../../common/schema/RequestTaskGroup";

export async function createTaskGroup(taskGroup: RequestTaskGroup){
    const functionUrl = 'ここにFirebase Functions上のURLを書く';

    try {
        const response = await axios.post<RequestTaskGroup>(functionUrl, taskGroup);
        return response.data;
    } catch (error) {
        console.error('apiでキャッチしたエラー', error);
        throw error;
    }
}
  • 肝はawait axios.post<RequestTaskGroup>(functionUrl, taskGroup)
    • axiosは第一引数で対象APIのURLを、第二引数でAPIに送る値を書く
    • このためにメソッドでtaskGroupを受け取る。
      • さっきhandleCreateTaskGroupで送ったやつ
    • 「フロントとバックの繋ぎこみ」ってなんなのかわからない時期長かったけど、接点になってる部分はフロントで書くapi用のコード。
      • ここで対象APIを指定して値を送っていて、そのための値の準備としてフロントの他のコードが存在するという感じ。

振り返り

  • もっと細かい粒度で書かないと定着には繋がらなそう
    • こまめに書いていく
  • 1日で1つをちゃんと定着させる、というのが大事な気がしていて、その日に学んだことを何か一個に絞って記事にしていく
    • 日によってはいくつもの色んなことを学ぶだろうけど、何かしらピンを打たないとどれも定着せずに終わりそう
  • 今回で言うと、ネストする型定義やその型の送り方、mapの使い方などは一定身についた気がする
    • 型定義の具体についてはケースバイケースという印象で、そこは今後学んで記事にしていく

Discussion