対象バージョン: expo-notifications 0.32.17 / iOS
挙動がバージョンに依存する話なので、読む前にご自身のpackage.jsonを確認してください。
結論
Notifications.SchedulableTriggerInputTypes.DATE は、名前から想像されるような「日付を指定するトリガー」ではありません。iOSネイティブ側では UNCalendarNotificationTrigger ではなく、UNTimeIntervalNotificationTrigger に変換されます。
つまり 0.32.17 の時点では、DATE と TIME_INTERVAL の最終的な挙動に差はありません。
ただし私は、絶対時刻の通知には TIME_INTERVAL を明示的に使うルールにしています。理由は記事の後半に書きます。
なぜ気になったか
株主優待のクロス取引(つなぎ売り)を管理するiOSアプリを個人開発しています。このアプリの中心機能は「現渡しを忘れないためのリマインダー通知」です。
現渡しは、権利落ち日の寄り付き前に注文を出す必要があります。東証が開くのは9時なので、通知は日本時間の朝8時に鳴らなければ意味がありません。
ここで問題になるのが、ユーザーが海外にいるケースです。時差のある場所に滞在していても、通知は日本時間の8時に鳴ってほしい。「現地時刻の8時」に鳴っても、そのときには日本の寄り付きは終わっています。
要件を整理すると、こうなります。
- 欲しいもの: 特定の絶対時刻(JSTの2026-09-29 08:00という一瞬)に発火する通知
- 困るもの: 端末のタイムゾーンに応じて発火時刻が変わる通知
expo-notifications のトリガーには DATE と TIME_INTERVAL があります。名前だけ見ると、DATE は「日付を指定する」=カレンダー的なもので、端末のタイムゾーンに引っ張られそうに見えます。実際のところどうなのか、ソースを読んで確認しました。
JS側を追う
scheduleNotificationAsync.ts に、トリガー引数をネイティブへ渡す形式に変換する処理があります。
DATE を渡すと parseDateTrigger() が呼ばれ、{ type: 'date', timestamp } という形になります。この時点で日付はエポックミリ秒に潰れています。
TIME_INTERVAL のほうは parseTimeIntervalTrigger() が処理し、{ type: 'timeInterval', seconds, repeats } になります。
JS層では、まだ両者は別物です。
Swift側を追う
分岐点は SchedulerModule.swift の triggerFromParams() にある switch文です。type の値を見て、対応する TriggerRecord の実装へ振り分けています。
TIME_INTERVAL 側(Records.swift の TimeIntervalTriggerRecord)は素直です。
trigger = UNTimeIntervalNotificationTrigger(timeInterval: self.seconds, repeats: self.repeats)
問題は DATE 側(同じく Records.swift の DateTriggerRecord)です。
public func toUNNotificationTrigger() throws -> UNNotificationTrigger? {
let timestamp: Int = Int(self.timestamp / 1000)
let date: Date = Date(timeIntervalSince1970: TimeInterval(timestamp))
var trigger: UNNotificationTrigger?
try EXUtilities.catchException {
trigger = UNTimeIntervalNotificationTrigger(timeInterval: date.timeIntervalSinceNow, repeats: false)
}
return trigger
}
(expo-notifications はMITライセンスです)
UNCalendarNotificationTrigger は出てきません。タイムスタンプから Date を復元したあと、timeIntervalSinceNow で「スケジュールを実行した瞬間から何秒後か」を計算し、その秒数を interval トリガーに渡しています。
DATE も TIME_INTERVAL も、最終的には同じ UNTimeIntervalNotificationTrigger に収束します。差は、秒数を自分で計算するか、ライブラリに計算させるかだけです。
そして interval トリガーは、スケジュール時点を起点にした純粋な経過時間で発火します。端末のタイムゾーンを途中で変えても、発火する瞬間は動きません。
つまり冒頭の心配は、少なくとも 0.32.17 では杞憂でした。DATE を使っても、通知は絶対時刻で鳴ります。
では、なぜDATEを使わないのか
ここからは私の判断であり、事実というより方針の話です。
私は自分のプロジェクトのCLAUDE.mdに「DATEを使わない」と書いています。ソースを読んだ結果として挙動に差がないと分かった今でも、このルールは残しました。理由は3つあります。
1. これは公開APIの契約ではなく、実装詳細だから
ドキュメントやJSDocのコメントは、DATEトリガーを「次の正時に一度だけ発火させる」といったカレンダー的な用途で例示しています。「内部的には経過時間に変換されます」とは書かれていません。書かれていないということは、変えても契約違反ではないということです。
2. 実際に何度も変更されている箇所だから
CHANGELOGを遡ると、DateTrigger の扱いは繰り返し手が入っています。古いところでは「Fixed interpretation of Date and number triggers on iOS」という記述があり、比較的新しいバージョンでも Android側の DateTrigger 簡略化などが入っています。安定して固定された部分ではありません。
将来のアップデートで「DATEなんだから素直に UNCalendarNotificationTrigger にすればいい」という、ある意味より自然な実装に変わる可能性は否定できません。**これは推測です。**そうなるという情報を持っているわけではありません。
3. もし変わった場合、国内のテストでは絶対に気づけないから
ここが一番怖い点です。
仮に DATE がカレンダートリガーになったとして、日本国内で開発している限り、現地時刻=JSTなので何も起きません。単体テストも通ります。実機で触っても正常に鳴ります。壊れていることが観測できるのは、海外に滞在しているユーザーの端末だけです。
こちらのQAをすり抜けて、ユーザー側でだけサイレントに壊れる。しかも壊れ方が「通知が来ない」なので、ユーザーからは「アプリが動かない」としか見えません。
ライブラリ更新のたびに node_modules の Swift を読み直すのは現実的ではないので、そもそも依存しないことにしました。
実際の実装
秒数は自分で計算して TIME_INTERVAL に渡しています。
const seconds = Math.max(1, Math.round((notification.fireAt.getTime() - now.getTime()) / 1000));
Math.max(1, ...) は、発火時刻が「1秒未満の未来」だったときに秒数が0になるのを防ぐためのガードです。
この書き方には、意図を明示できるという副次的な効果もあります。fireAt - now という式そのものが「絶対時刻を経過時間に変換している」という設計判断を表しているので、あとから読んだときに何をやっているか分かります。DATEを渡していたら、この判断はライブラリの中に隠れたままです。
まとめ
- expo-notifications 0.32.17 の iOS実装では、DATE も TIME_INTERVAL も
UNTimeIntervalNotificationTriggerになる - 絶対時刻の通知が必要なら、現時点ではどちらでも動く
- ただしDATE→interval変換は実装詳細であり、依存すると将来のアップデートで壊れる可能性がある
- しかもその壊れ方は、開発環境のタイムゾーンでは再現しない
ライブラリのAPI名から挙動を推測するのをやめて、ソースを読むと10分で答えが出ました。node_modules を読むのは面倒ですが、名前と実装が食い違っている箇所は、読まないと一生気づけません。