「2つの日付の差は何日か」だけなら引き算で終わる。だが「◯年◯ヶ月◯日」で出せ、和暦(昭和◯年)でも入力させろ、となると途端に面倒になる。date-fns や dayjs を入れれば済む話だが、この程度なら標準の Date で十分書ける——ただし地味にハマるポイントが3つある。
日付の差・◯日後/前・年齢(和暦対応)を計算するツールをぱんだツールズの1機能として作った。ライブラリなし・ブラウザ完結。
この記事では、日付計算を素の Date で実装するときに踏む3つの落とし穴——「◯年◯ヶ月◯日」差分の桁借り、月末日の求め方、和暦変換——を解説する。
ハマり1:「◯年◯ヶ月◯日」は桁借りが要る
合計日数は単純で、ミリ秒の差を1日のミリ秒で割るだけ。
const msPerDay = 1000 * 60 * 60 * 24
const totalDays = Math.round((to.getTime() - from.getTime()) / msPerDay)
面倒なのは「2年3ヶ月15日」のような複合表示。年・月・日を別々に引き算すると、日や月がマイナスになる。これを桁借りで繰り上げる必要がある。筆算の繰り下がりと同じ考え方。
let years = absTo.getFullYear() - absFrom.getFullYear()
let months = absTo.getMonth() - absFrom.getMonth()
let days = absTo.getDate() - absFrom.getDate()
if (days < 0) {
months -= 1
// 「終了日の前月」の日数を借りてくる
const prevMonth = new Date(absTo.getFullYear(), absTo.getMonth(), 0)
days += prevMonth.getDate()
}
if (months < 0) {
years -= 1
months += 12
}
days がマイナスなら、months から1ヶ月借りてその月の日数を days に足す。months がマイナスなら years から12ヶ月借りる。ここで「借りてくる日数」を固定の30日にしてはいけないのが肝で、2月は28/29日、各月は28〜31日とバラバラ。借りるのは必ず「実際のその月の日数」でないと、1月31日 → 3月1日 のような月またぎがズレる。
その「実際の月の日数」をどう取るかが、次のハマりポイント。
ハマり2:月末日は new Date(y, month, 0) で取る
JavaScript の Date には「その月の日数」を返す API がない。だがコンストラクタの日に 0 を渡すと前月の末日になるという挙動を使うと一発で取れる。
// 「日=0」は前月の最終日に正規化される
new Date(2024, 2, 0).getDate() // → 29(2024年2月は閏年なので29日)
new Date(2023, 2, 0).getDate() // → 28(2023年2月)
new Date(2024, 1, 0).getDate() // → 31(1月)
new Date(y, m, d) の d に 0 を入れると「m 月の0日目」=「m-1 月の最終日」に丸められる。月インデックスは0始まりなので、new Date(2024, 2, 0) は「3月(index 2)の0日目」=2月29日。この getDate() がそのまま「2月の日数」になる。
上の桁借りコードの new Date(absTo.getFullYear(), absTo.getMonth(), 0) は、「終了日の月インデックス」をそのまま渡すことで「終了日の前月の末日」を取り、その日数を借りている。閏年判定を自分で書かなくても、Date の正規化が閏年を勝手に処理してくれる。
同じ理屈で、◯日後 の計算も「日にそのまま加算」すれば月またぎ・年またぎが自動で正規化される。
const resultDate = new Date(baseDate.getTime() + daysNum * 24 * 60 * 60 * 1000)
ちなみにこれが「30日後」と「1ヶ月後」が別物になる理由でもある。日数ベースの加算は常に固定日数だが、「1ヶ月後」は月の長さに依存する。1月31日 + 1ヶ月 は2月31日が存在しないので2月28日(or 29日)になる。日付計算では「何を単位にするか」で答えが変わる。
ハマり3:和暦変換は「改元日」を正確に持つ
和暦(令和・平成・昭和…)対応は、元号ごとの正確な改元日をテーブルに持つのが出発点。「昭和は1926年から」では足りない。昭和の開始は1926年12月25日で、それ以前の1926年は大正15年。改元日を持たないと境界の年で1年ズレる。
const GENGO = [
{ name: '令和', startYear: 2019, startMonth: 5, startDay: 1 },
{ name: '平成', startYear: 1989, startMonth: 1, startDay: 8 },
{ name: '昭和', startYear: 1926, startMonth: 12, startDay: 25 },
{ name: '大正', startYear: 1912, startMonth: 7, startDay: 30 },
{ name: '明治', startYear: 1868, startMonth: 1, startDay: 25 },
] as const
和暦→西暦は 開始年 + 元号年 - 1。昭和65年なら 1926 + 65 - 1 = 1990年。ただし「昭和元年の1月」のような存在しない日付を弾く必要がある(昭和は12月25日始まりなので昭和元年に1月は無い)。改元日より前ならエラーにする。
function gengoToDate(gengo: GengoName, nen: number, month: number, day: number): Date | null {
const g = GENGO.find((x) => x.name === gengo)
if (!g) return null
const year = g.startYear + nen - 1
const date = new Date(year, month - 1, day)
if (isNaN(date.getTime())) return null
// 元号の開始日より前は存在しない日付
const gengoStart = new Date(g.startYear, g.startMonth - 1, g.startDay)
if (date < gengoStart) return null
return date
}
new Date(...) が Invalid Date(isNaN(date.getTime()))になるケースと、改元日より前のケースの両方を null で弾く。これで「昭和元年3月」のような実在しない和暦を入力バリデーションで検出できる。
設計の小ネタ:年齢計算は差分計算の使い回し
年齢計算は専用ロジックを書かなくていい。「生年月日から今日までの差」の年部分が満年齢そのものなので、ハマり1で作った calcDiff をそのまま再利用できる。
const diff = calcDiff(birthDate, today)
const age = diff.years // ◯年◯ヶ月◯日 の「年」が満年齢
calcDiff は桁借りで「◯年◯ヶ月◯日」を正確に出すので、その years を取れば「誕生日が来たかどうか」も含めて正しい満年齢になる。月日がまだ来ていなければ桁借りで years が1減るため、単純な 今年 - 生年 のズレ(誕生日前なのに1歳多い)が起きない。差分計算をきちんと作っておくと、年齢計算が無料でついてくる。
まとめ
日付計算は「引き算するだけ」に見えて、複合表示・月末・和暦で地味に詰まる。
- 「◯年◯ヶ月◯日」は年・月・日を引いてから桁借りで繰り上げる。借りる日数は固定30日でなく実際の月の日数
- 月の日数は
new Date(y, month, 0).getDate()。日に0を渡すと前月末日に正規化され、閏年も自動処理 -
◯日後もgetTime()への加算で月・年またぎが自動正規化。だから「30日後」と「1ヶ月後」は別物 - 和暦は正確な改元日テーブルを持ち、
開始年 + 元号年 - 1。改元日より前とInvalid Dateを弾いて実在しない日付を検出 - 年齢計算は差分計算の
yearsを使い回すだけ。桁借りのおかげで誕生日前後も正確
Date の正規化挙動(特に「日=0で前月末日」)を知っていると、閏年判定すら自分で書かずに済む。ライブラリを入れる前に、標準の Date でどこまで戦えるか見極める良い題材だった。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。