⏱️

Temporal: JavaScript で"時間"を扱う際の使用方法(と個室ブース予約サイトでの活用例)

2023/05/14に公開
2

はじめに

この記事では、JavaScriptの新しい標準APIとなりつつある Temporal について、その使用方法を紹介するものです。

具体的な活用例として、私が運営しているコワーキングスペース茅場町 Co-Edo の 個室ブース予約サイト でTemporalをどのように使用したかを具体的に示します。

JavaScriptと日付:過去の課題

JavaScriptで日付と時間を扱うためには、これまで主にDateオブジェクトを使用してきました。
このDateオブジェクトは、開発者にとって悩ましい問題を抱えています。

不整合な月のインデックス

JavaScriptのDateオブジェクトでは、月のインデックスが0から始まります。
1月は0、12月は11となります。
これは(日付は1から始まることもあわせて)直感的ではないため、混乱を招きやすいです。

const date = new Date(2023, 11, 4) // 2023年12月04日

タイムゾーンの問題

JavaScriptのDateオブジェクトはブラウザのローカルタイムゾーンを使用しますが、タイムゾーンを明示的に扱う手段が存在しません。そのため、異なるタイムゾーンで日付と時間を扱う場合、混乱が生じる可能性があります。

日付文字列のパースの不一致

JavaScriptのDateオブジェクトで日付文字列をパースする際、ブラウザによって結果が異なる可能性があります。これは開発者にとって不便さをもたらします。

const date = new Date('2023-12-04') // この結果はブラウザによって異なる可能性があります

日時以外の表現が扱えない

JavaScriptのDateオブジェクトは"日時"を扱うことに特化しています。
しかし、"時間のみ"や"日付のみ"、さらには"期間"といった概念を扱う方法がありません。
これは、特定のアプリケーションでこれらの概念を扱う必要がある場合、開発者にとって制約となります。

以上のような問題が生じるため、新しい解決策が必要となりました。
それがTemporalです。

次のセクションでは、Temporalがこれらの問題をどのように解決するのかを見ていきましょう。

Temporalとは何か

Temporalは、JavaScriptで日付と時間を扱う新しい標準APIとして提案されました。
その目的は、従来のDateオブジェクトの持つ問題を解決し、より直感的で柔軟な日付と時間の操作を可能にすることです。

Temporalの主な特徴

TemporalはDateオブジェクトが持つ多くの問題を解決します。

月のインデックスの整合性

Temporalでは、月のインデックスが1から始まり、混乱を招くことなく扱うことができます。
1月は1、12月は12となります(普通ですね!)

import { Temporal } from 'temporal-polyfill'
const date = Temporal.PlainDate.from({ year: 2023, month: 12, day: 4 }) // 2023年12月04日

タイムゾーンの明示的な扱い

Temporalでは、日付と時間を扱う際にタイムゾーンを明示的に指定することが可能です。
例えば海外旅行者が宿泊するホテルの予約サイトで、現地時間と利用者の地元の時間を同時に扱うような場合でも混乱を避けることができます。

日付文字列の一貫性

Temporalでは、日付文字列のパースがブラウザ間で一貫しています。
これにより、開発者はブラウザごとの違いに悩まされることなく、日付文字列のパースを行うことができます。

"時間のみ"、"日付のみ"、"期間"の扱い

Temporalは"時間のみ"、"日付のみ"、そして"期間"といった概念も扱うことができます。
例えば労働時間の管理や、特定の期間にわたるイベントのスケジューリングなど、これらの概念を扱う必要があるアプリケーションでも、開発者は自由にそれらを表現することが可能です。

以上のような特徴により、TemporalはJavaScriptで日付と時間を扱う際の新たな選択肢となります。

次のセクションでは、Temporalを使用して具体的なプロジェクトを実装する方法を説明します。

Temporalの主要な機能と利点

Temporal APIは、日付と時間を扱うための様々なクラスとメソッドを提供します。
Temporalが提供する主要なAPIとその機能、そしてそれがJavaScript開発者にどのような利点をもたらすかについて、かんたんに説明します。

PlainDate

PlainDateは、年、月、日を表すためのクラスです(時間やタイムゾーン情報は含まれません)

import { Temporal } from 'temporal-polyfill'
const date = Temporal.PlainDate.from({ year: 2023, month: 12, day: 4 }) // 2023年12月04日
console.log(date.toString()) // 2023-12-04

このAPIは、日付のみを必要とするケースに適しています。
例えば、誕生日や記念日のような特定の日を表現する際に有用です。

PlainTime

PlainTimeは、時間を表すためのクラスで、時間、分、秒、ミリ秒を表現します(日付やタイムゾーン情報は含まれません)

import { Temporal } from 'temporal-polyfill'
const time = Temporal.PlainTime.from({ hour: 13, minute: 45, second: 30 }) // 13時45分30秒
console.log(time.toString()) // 13:45:30

このAPIは、時間のみを必要とするケースに適しています。
例えば店舗の営業時間やスケジュールの時間帯を表現する際に有用です。

PlainDateTime

PlainDateTimeは、日付と時間を表すためのクラスです。
ただし、タイムゾーン情報は含まれません。

import { Temporal } from 'temporal-polyfill'
const dateTime = Temporal.PlainDateTime.from({ year: 2023, month: 12, day: 4, hour: 13, minute: 45, second: 30 }) // 2023年12月04日 13時45分30秒
console.log(dateTime.toString()) // 2023-12-04T13:45:30

このAPIは、日付と時間の両方を扱う必要があるシナリオに適しています。例えば、特定の日時に予定されたイベントを表現する際に有用です。

Duration

Durationは、一定の時間間隔を表すためのクラスです。
年、月、日、時間、分、秒、ミリ秒の 間隔 を表現することができます。

import { Temporal } from 'temporal-polyfill'
const duration = Temporal.Duration.from({
  years: 1,
  months: 2,
  days: 3,
  hours: 4,
  minutes: 5,
  seconds: 6,
  milliseconds: 789,
  microseconds: 123,
  nanoseconds: 456,
}) // 1年2ヶ月3日4時間5分6.789123456秒
console.log(duration.toString()) // P1Y2M3DT4H5M6.789123456S

このAPIは、期間を表現する必要があるケースに適しています。
例えば、プロジェクトの期間や、特定のイベントが続く期間を表現する際に有用です。

なお P1Y2M3DT4H5M6.789123456S は、ISO 8601の日付等を表現するための文字列です。

ISO 8601との互換性

TemporalはISO 8601の表現形式と完全に互換性があります。
これは国際的な日付と時間の表現形式の標準で、明確で一貫性もあり広く利用されています。

ISO 8601の期間表記について

ISO 8601の期間表記では、'P'は"Period(期間)"を表し、期間の始まりを示します。
その後に続く数値と文字はそれぞれ 期間の長さ単位 を示します。

  • 'Y'は年(Year)を表します。
  • 'M'は、日付部分においては月(Month)を表します。ただし、時間(Hour)の後に続く'M'は分(Minute)を表します。
  • 'W'は週(Week)を表します。
  • 'D'は日(Day)を表します。
  • 'T'は時間要素の開始を示します。
  • 'H'は時間(Hour)を表します。
  • 時間要素が開始された後の'M'は、分(Minute)を表します。
  • 'S'は秒(Second)を表します。

週の扱い

Temporalでは週を扱う概念も明確にされています。
ISO 8601では、週は月曜日から始まり、年の最初の木曜日が含まれる週をその年の第1週とします。
Temporalはこれに準拠しています。

ZonedDateTime

ZonedDateTimeは、日付、時間、そしてタイムゾーンを含むクラスです。

import { Temporal } from 'temporal-polyfill'
const zonedDateTime = Temporal.ZonedDateTime.from({ year: 2023, month: 12, day: 4, hour: 13, minute: 45, second: 30, timeZone: 'Asia/Tokyo' }) // 2023年12月04日 13時45分30秒、東京時間

このAPIは、特定のタイムゾーンでの日時を表現する必要があるケースに適しています。
例えば、国際的なビジネスの会議時間をスケジューリングする際などに有用です。

PlainとZonedの違い

Temporalには、時間要素を持たないPlainな日付や時間、および特定のタイムゾーンを持つZonedな日付や時間の両方を扱うことができます。

Plainな日付や時間(例えばPlainDatePlainTimePlainDateTime)は、時間帯やデイトライトセービングタイム(夏時間)の影響を受けません。
これらは、特定の時点を表すのではなく、あくまで日付や時間の概念を表します。
(また、PlainYearMonthPlainMonthDayクラスを使うことで、年と月のみ、月と日のみの情報を持つこともできます)

そのため、Plainな日付や時間を使用すると、タイムゾーンによる違いを気にすることなく、一貫した日付や時間の操作が可能になります。

一方、Zonedな日時(具体的にはZonedDateTime)は、特定のタイムゾーンを持つ日時を表します。
これは、特定の地域での実際の日時を表すために使用されます。
例えば、特定の地域での会議の開始時間や、国際的なイベントのスケジューリングなどに便利です。

この章のまとめ

以上のように、Temporalは、日付と時間に関連するさまざまなシナリオをカバーするための豊富なAPIを提供しています。
これにより、JavaScript開発者は、従来のDateオブジェクトでは解決しきれなかった問題を解決し、より直感的で柔軟な日付と時間の操作を実現することが可能になります。

Temporalを使った実践的な例:Co-Edoの個室ブース予約システム

Co-Edoの個室ブース予約システムでは、"日付のみ"や"時間のみ"の取扱いの観点から、Dateではなく新しいJavaScriptの日付と時間APIであるTemporalを採用しました。

Temporalの採用理由

予約システムでは、「日付」「開始時間」「終了時間」「利用時間」といった概念を扱う必要があります。
もちろん既存のDateオブジェクトでも、これらを表現することは可能です。
しかし、より本質的で柔軟な操作を実現するためにはTemporalの採用が有効という仮説をたてました。

たとえば、ある利用者が「平日の12:00~14:30」での利用を検討していたとします。
このとき、その時間帯で利用可能な「日付」のみを絞り込めれば、どの日を利用するか決めやすいでしょう。
「12:00~14:30」のような特定の時間帯で利用可能な日付を利用者に提示する機能は、Temporalの"日付のみ"や"時間のみ"といった取り扱いが、より直接的です。

また、Co-Edoは東京都中央区にあり、仮に利用者が海外旅行者であったとしても JST で予約をしてもらうことがもっとも混乱が少ないでしょう。
タイムゾーンによる混乱を避けられる点もTemporal採用の理由の一つです。

Temporalと周辺のライブラリ

一方で、Temporalはまだ新しいAPIであり、Dateに比べて周辺のライブラリが少ないという弱点があります。
この問題を独自の関数群を用意することで解決しました。
たとえば次のような関数を用意しました。

// 一部の関数名を示します
'isBefore',
'isSame',
'isAfter',
'isSameOrBefore',
'isSameOrAfter',
'isBetween',
'plainDateTimeToUnixMilliseconds',
'unixMillisecondsToPlainDateTime',
'unixMillisecondsToPlainDate',
'unixMillisecondsToPlainTime',
'convertToJpDateString',
'convertToTimeString',
'convertToTimeRangeString',
'isDateTimeInRange',

これらの関数は、Temporalの持つ基本的な機能を補完し、予約システムの具体的なニーズに対応するために実装しています。

Temporalを用いた具体的な実装

上記の一部isBetweengetCurrentTemporalNowについて、その実装例をご紹介します。

まずisBetween関数です。
この関数は、ある日付・時間が指定した時間範囲内に存在するかどうかを確認します。

import { Temporal } from 'temporal-polyfill'

export const isBetween = (date: Temporal.PlainDateTime, start: Temporal.PlainDateTime, end: Temporal.PlainDateTime): boolean => {
  return Temporal.PlainDateTime.compare(date, start) >= 0 && Temporal.PlainDateTime.compare(date, end) <= 0
}

ちなみにこの関数は、引数の型をTemporal.PlainTimeとすれば、時間のみの比較が可能です。
(実際はTemporal.PlainDateも含めて、柔軟に比較できるように実装しています)

つづいてgetCurrentTemporalNow関数です。
この関数は、現在の日付と時間をTemporalのオブジェクトとして返します。

import { Temporal } from 'temporal-polyfill'

export const getCurrentTemporalNow = (): Temporal.PlainDateTime => {
  const timeZone = Temporal.TimeZone.from('Asia/Tokyo')
  return Temporal.Now.zonedDateTimeISO(timeZone).toPlainDateTime()
}

これらのラッパー関数によって、Temporalの扱いに慣れていない開発者(僕)も、より直感的に扱うことができます。
また、これらについてユニットテストを書いておくことで、リファクタリングや機能追加時も安心して臨めます。

UI(フォーム等)、内部のステート、データベースに保存する値の型

予約システムでは次のフレームワーク・サービスを使用しています。

  • Nuxt 3 (Vue.js 3)
  • Firebase (Firestore)

内部のステートはすべて Temporal のオブジェクトとして保持しています。
データベースに保存する際は、Temporalのオブジェクトを(Firestoreのconverter層で)FirebaseのTimestamp型に変換して保存しています。

フォームでは、日付選択には input[type=date] を使用し、時刻の選択には select を使用しています。
Temporal.PlainDateTemporal.PlainTimeを(.toString()を使用し)文字列に変換してinputselectに渡しています。

components/DateInput.vue
<script setup lang="ts">
import { Temporal } from 'temporal-polyfill'
const props = defineProps<{
  modelValue: Temporal.PlainDate | null,
}>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: Temporal.PlainDate | null): void
}>()

const inputValue = computed({
  get: () => props.modelValue?.toString() ?? '',
  set: (value: string) => {
    emit('update:modelValue', value ? Temporal.PlainDate.from(value) : null)
  },
})
</script>

<template>
  <input
    v-model="inputValue"
    type="date"
  />
</template>

これにより、通常はすべてのデータをTemporalのオブジェクトとして扱うことができます。
"時間のみ" "日付のみ" に関する操作を行う際のコードが取り扱えるので便利です。

以上のようにTemporalを活用することで日付や時間の操作に関する問題を効果的に解決し、必要な改善を行っています。

Temporalを利用する上での注意点

Temporalは強力で直感的なAPIを提供していますが、その利用にはいくつかの注意点があります。

まだステージ3

まず、Temporalは執筆時点でまだTC39プロセスのステージ3にあります。
これはまだJavaScriptの公式標準として確定していないことを意味します。
そのため、将来的にAPIが変更される可能性があることを覚えておく必要があります。

polyfillの利用

現時点ではTemporalを使用するにはpolyfillを利用する必要があります。
polyfillは、ブラウザがまだサポートしていない新しい機能をエミュレートするためのコードで、TemporalのpolyfillはTemporalの全機能を提供します。

しかし、polyfillの利用には注意が必要です。
polyfillは全てのブラウザや環境で正確に動作するわけではないため、テストとデバッグが重要です。
またpolyfillを使用すると、ページのロード時間など、パフォーマンスにも影響を与える可能性があります。

タイムゾーンの扱い

Temporalはタイムゾーンを扱うことができますが、その扱いには注意が必要です。
タイムゾーンについて気を配らないと、結果的に間違った日時を取り扱う可能性があります。

周辺ライブラリの不足

現時点ではTemporalに対応した周辺ライブラリがまだ十分に存在していません。
特定のタスクを簡単に行うためのユーティリティ関数を自分で作成する必要があるかもしれません。

以上のような点を考慮に入れつつ、Temporalの利用を検討する必要があります。

まとめ

Temporalは、JavaScriptで日付と時間を操作するための新しい強力なAPIです。
その目的は、既存のDateオブジェクトの多くの問題を解決し、開発者が日付と時間をより直感的に、そして正確に扱うことを可能にすることです。

TemporalはISO 8601に準拠した日付と時間の表現を提供し、また、"時間のみ"、"日付のみ"、"日時"、"期間"、"年月のみ"、"月日のみ"といったさまざまな形式を扱うことができます。さらに、Temporalはタイムゾーンを考慮した日付と時間の操作を可能にします。

しかし、Temporalは新しいパラダイムのAPIであるため、その利用にはいくつかの注意点があります。
Dateとの違いや、何が得意なのかを理解したうえで、Temporalを活用することが重要です。
新しいからといって何が何でも利用するというより、どのような問題を解決するために利用するのかを考えることが大切です。

もし、開発したいアプリケーションが既存のDateオブジェクトでは表現できない日付と時間を扱う必要があるのであれば、Temporalの利用を検討してみてください。

Discussion

Kazunori IriyaKazunori Iriya

参考になりました。ありがとうございました。

https://zenn.dev/coedo/articles/typescript-temporal#週の扱い の例示は私が試した @js-temporal/polyfill の実装ではうまく処理できないようでしたのでご一報です。( with() で置き換えることが仕様では出来るようでしたが同様にだめでした。weekOfYear, dayOfWeek プロパティへのアクセスは大丈夫でした。)はやくドラフトが取れればいいなと思います、Temporal。

coedocoedo

コメントありがとうございます。
読み取り専用の値を使って日付を設定してるので、たしかに例示したコードがおかしいですね。

そしていま StackBlitz の環境で確認したところ temporal-polyfill では、weekOfYearyearOfWeek が正しい値で返ってきませんでした。
いったん該当箇所のコードを削除しておこうと思います…