👷

TypeScriptで日付文字列をどう扱うか

2024/09/27に公開

この記事では、フロントエンドで日付文字列を安全に扱い、運用しやすいアプリケーションを構築する方法について解説します。
この記事のサンプルコードにはReactを使用しますが、他の技術でも応用できると思います。

またZodとOpenAPI Generatorを使った実装方法については、以下の記事で詳しく解説していますのでぜひご覧ください。

https://tech.up-sider.com/entry/2024/09/26/091528

要約

  • 様々な日付文字列がstring型で定義されていると、区別がつかず扱いづらい
  • 一部のコンポーネントで日付をDate型で扱うことで表示不正を防ぐことができる
  • APIに関連する日付文字列にBrand型を使用すると安全で堅牢になる

日付文字列とは

  • 画面に表示する日付文字列
  • ユーザー入力コンポーネントで扱う日付文字列
  • APIリクエストの日付文字列
  • APIレスポンスの日付文字列

など日付文字列と言っても様々な日付文字列が存在する。
これらについて主にAPIに関連する日付文字列とそれ以外に分類し、課題解決方法を考えていく。

課題

  • APIレスポンスの日付文字列をコンポーネントで表示する
  • ユーザー入力コンポーネントの値をAPIリクエストパラメータに使用する

APIレスポンスの日付文字列をコンポーネントで表示する

前提として、APIレスポンスの日付文字列をそのままUI上で表示せず適切なフォーマットを行い表示することを想定する。

const DateField = ({ value }: { value: string }) => {
  return <p>{value}</p>
}

DateFieldコンポーネントのvalueが想定しているstringが、APIレスポンスの日付文字列なのか、UIで表示するために変換後の文字列なのか区別がつかない。
APIレスポンスの日付文字列であればUIで表示するために変換が必要であり、変換し忘れると不正なフォーマットで表示される。
変換後の文字列であればそのまま表示すべきであるが、もう一度変換処理を行ってしまうと無駄であり、不具合となる可能性もある。

解決案

日付表示コンポーネントではDate型を使用する
const DateField = ({ value }: { value: Date }) => {
  return <p>{format(value, 'yyyy年MM月dd日')}</p> // formatはdate-fns想定
}

type Order = {
  shipmentDate: string
}
const ShipmentDate = ({ shipmentDate }: { shipmentDate: Order['shipmentDate'] }) => {
  // return <DateField value={shipmentDate} />
  // Error: Argument of type 'string' is not assignable to parameter of type 'Date'

  return <DateField value={new Date(shipmentDate)} /> // OK
}

APIレスポンスの日付はstring型、日付表示コンポーネントのpropはDate型とすることで、日付表示コンポーネントへ渡したAPIレスポンスの日付文字列をそのまま表示してしまうことがなくなる。
またDate型をそのままDOMに表示できないため、日付表示コンポーネント内でformatが必須になり、どのコンポーネントでフォーマットをする必要があるか明確になる。
さらにブレークポイントにより異なるフォーマットで表示することなども簡単にできる。
しかし上記の例のShipmentDateコンポーネントののshipmentDateはstring型であれば空文字であろうが日付でなかろうが何でも受け付けてしまう。
またチーム全員でこの設計を共有できるかと言われると難しく感じる。

APIレスポンスの日付文字列にBrand型を使用する
type ApiDateString = string & { __brand: 'ApiDateString' }

type Order = {
  shipmentDate: ApiDateString
}

const ShipmentDate = ({ shipmentDate }: { shipmentDate: Order['shipmentDate'] }) => {
  return <DateField value={new Date(shipmentDate)} />
}
Brand型について

string & { __brand: 'ApiDateString' } の値はstring型であることに変わりはないが、string型にダミーのプロパティを定義することで型として区別される。

  • 異なるBrand型へ代入できない

    const apiDateString = '2024-01-20' as string & { __brand: 'ApiDateString' }
    const otherString: string & { __brand: 'OtherString' } = apiDateString // Error
    
  • string型やstring literalをstringのBrand型へ代入できない

    const s = 'string literal'
    const apiDateString: string & { __brand: 'ApiDateString' } = s // Error
    
  • stringのBrand型をstring型へ代入できる

    const apiDateString = '2024-01-20' as string & { __brand: 'ApiDateString' }
    const s: string = apiDateString // OK
    

APIレスポンスにApiDateStringをマッピングする設計をすることで、APIから返却された文字列であることが明確になり、APIから返却されていない文字列を渡せなくなる。

値を検証することもできる

値が保証されるものではないので、心配であれば値を検証してもいいかもしれないが過剰にも感じる。

const ShipmentDate = ({ shipmentDate }: { shipmentDate: Order['shipmentDate'] }) => {
  const res = useMemo(() => {
    if (/* 検証失敗 */) {
      // Sentryに飛ばすなど
      throw new Error('ApiDateStringの値が不正')
    }
    return new Date(shipmentDate)
  }, [shipmentDate])

  return <DateField value={res} />
}

ユーザー入力コンポーネントの値をAPIリクエストする

ユーザー入力コンポーネントの値をAPIリクエストする際にstring型では正しいフォーマットである保証がない。
不正なフォーマットでリクエストを行うと不具合が発生する可能性がある。

const InputDate = (props: {
  value: string,
  onChange: (v: string) => void
}) => { /* 実装は省略 */ }

type PostParams = { shipmentDate: string }

// args.shipmentDateが正しいAPIリクエストのフォーマットの文字列なのか型からわからない
const postDate = (args: PostParams) => { /* 実装は省略 */}

const Form = () => {
  const [formData, setFormData] = useState<{ shipmentDate: string }>({ shipmentDate: '' })

  const handleSubmit = () => {
    // バリデーション
    if (!formData.shipmentDate) {
      return
    }
    postDate(formData)
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputDate
        value={formData.shipmentDate}
        onChange={
          (v) => setFormData(
            prev => ({ ...prev, shipmentDate: v })
          )
        }
      />
    </form>
  )
}

解決案

APIレスポンスの表示と同じように、ユーザー入力コンポーネントのvalueをDate型で扱うことでも解決できそうではある。
しかし、ここでは入力コンポーネントが何らかのライブラリに依存している (Date型で扱えるようにラップすればいいかもしれないが)、もしくは非制御コンポーネントとして扱いたいなど制約があるものと考え、string型で扱わないといけないとを想定する。

APIリクエストの日付文字列にBrand型を導入する

APIレスポンスと同様にリクエストにもBrand型を導入する。
APIレスポンスとリクエストの文字列のフォーマットは大抵同じであると考え使い回す。

const InputDate = (props: {
  value: string,
  onChange: (v: string) => void
}) => { /* 実装は省略 */ }

type ApiDateString = string & { __brand: 'ApiDateString' }

type PostParams = { shipmentDate: ApiDateString }

const postDate = (args: PostParams) => { /* 実装は省略 */}

const Form = () => {
  const [formData, setFormData] = useState<{ shipmentDate: string }>({ shipmentDate: '' })

  const handleSubmit = () => {
    // バリデーション
    if (!formData.shipmentDate) {
      return
    }
    postDate({
      ...formData,
      date: format(formData.shipmentDate, 'yyyy-MM-dd') as ApiDateString
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputDate
        value={formData.shipmentDate}
        onChange={
          (v) => setFormData(
            prev => ({ ...prev, shipmentDate: v })
          )
        }
      />
    </form>
  )
}

これによりpostDateのargsで扱う日付文字列はAPIリクエストのフォーマットであることが保証される。
といいたいがasを使って型を変換しているだけであり型安全かと言われるとそうではないので、
特定の変換用関数を定義しテストを行うべきだと感じる。
Brand型の性質上asを使用することは仕方ないかもしれない。

Brand型の定義、検証、変換に関してはZodを使用した方法が扱いやすかったので以下の記事で紹介しています。

https://tech.up-sider.com/entry/2024/09/26/091528

まとめ

紹介したコードではごまかしているFormの値の検証や、asを使用している箇所などまだ課題は残っているが、Brand型を導入することで日付文字列が安全に扱いやすくなったのではないかと感じています。
日時の場合はnumber型のUnixtime Brand型などを定義することで安全に扱うことができると思います。

Discussion