LocalDate.parseの修正では、「入力の形」と「実在する日付か」を別々に確認します。2026/02/30は、フォーマッターを指定すると例外にならず、2月28日に補正される場合があります。形式を合わせただけで入力チェックが完成したとは判断できません。
この記事でわかること
- スラッシュ形式の入力でDateTimeParseExceptionになる理由
- 「2月30日」が補正されるコードと、拒否するコードの違い
- 必須項目と任意項目で、未入力の扱いを分ける方法
- 対象コードとテストを動かし、修正を確かめる手順
ここでは架空の登録処理を想定します。請求日は必須、支払予定日は任意で、どちらも半角数字の年4桁・月2桁・日2桁をスラッシュで区切ります。利用者が入力していない場合と、入力した日付が間違っている場合を、別の結果として返します。画面やDBの処理は含めず、文字列を受け取る変換クラスに絞ります。
まず同じ「2026/02/30」で、補正と例外を比べる
次の実行例は、修正前のフォーマッターと、後で載せるDateInputsを同じ入力で呼び出します。ofPattern("uuuu/MM/dd")の既定はSMARTです。年・月と照合して、その月にない日を月末へ補正する場合があります。
package info.syachiku.samples.date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
public final class DateParsingDemo {
public static void main(String[] args) {
String input = "2026/02/30";
var patternDefault = DateTimeFormatter.ofPattern("uuuu/MM/dd");
System.out.println("既定の解析方針: " + patternDefault.getResolverStyle());
System.out.println("SMART: " + input + " -> "
+ LocalDate.parse(input, patternDefault));
try {
DateInputs.parseSlashDate(input);
throw new AssertionError("存在しない日付を受け付けました");
} catch (DateTimeParseException expected) {
System.out.println("STRICT: " + input + " -> DateTimeParseException");
}
System.out.println("必須・正常: " + DateInputs.parseSlashDate("2024/02/29"));
System.out.println("任意・未入力: " + DateInputs.parseOptionalDate(" "));
}
}実行結果は次のとおりです。SMARTでは受け付けてしまった値を、STRICTの変換処理は拒否しています。
既定の解析方針: SMART
SMART: 2026/02/30 -> 2026-02-28
STRICT: 2026/02/30 -> DateTimeParseException
必須・正常: 2024-02-29
任意・未入力: Optional.empty「例外を出さずにLocalDateを得る」のが目的なら、SMARTの結果も成功に見えます。しかし、入力された請求日をそのまま正しく記録する要件なら、存在しない日付の補正は不適切です。例外が消えたかだけでなく、どの入力を受け付ける約束かに照らして修正します。
なお、フォーマッターを省略したLocalDate.parse("2026-02-30")はISO形式の厳密な解析で失敗します。「parseは存在しない日付を必ず補正する」「必ず例外にする」のどちらも、フォーマッターを指定するコード全体の説明にはなりません。
例外メッセージの could not be parsed at index を読む
DateTimeParseException が出たら、getErrorIndex() の数字と getMessage() の文を並べて見ます。JDK 17.0.8で、既定の解析と配布コードの失敗を比べた結果です。
- ISO形式で読むと、区切りが合わない
LocalDate.parse("2026/02/28")→getErrorIndex() = 4Text '2026/02/28' could not be parsed at index 4
先頭を0と数えると5文字目は/です。入力の区切りと、既定のuuuu-MM-ddに相当するISO形式を照合します。- 書式は合うが、その日付が存在しない
DateInputs.parseSlashDate("2026/02/30")→getErrorIndex() = 0Text '2026/02/30' could not be parsed: Invalid date 'FEBRUARY 30'Invalid dateが出ているので、年・月・日の組み合わせを確認します。- サンプルの書式チェックで止まる
DateInputs.parseSlashDate("2026-02-28")→getErrorIndex() = 0日付は半角数字のuuuu/MM/ddで入力してください
この0は、サンプルが正規表現の不一致で例外を作るときに指定した値です。メッセージに示された入力仕様を確認します。
自分のコードで原因を追うときは、スタックトレースから例外を作った行を確認します。フォーマッターで失敗しているのか、アプリが書式のチェックで例外を作っているのかを切り分けられます。Java 17 APIの getErrorIndex() の説明。
直す前に、形式・存在日・未入力の三つを決める
例外名を見て区切り文字を置換する前に、入力仕様を確認します。このサンプルが受け付けるものは次のとおりです。日付の形と存在日が正しくても、将来日を許すか、締切日を超えていないかという業務上の条件は別に残ります。
| 入力 | 必須の請求日 | 任意の支払予定日 |
|---|---|---|
2024/02/29 | 2024-02-29 | 同じ日付を含むOptional |
2026/02/30 | 存在しないため拒否 | 存在しないため拒否 |
2026-02-28 | 区切りが違うため拒否 | 区切りが違うため拒否 |
2026/2/28 | 月が1桁なので拒否 | 月が1桁なので拒否 |
| null・空文字・空白だけ | 必須エラー | 未入力としてempty |
前後の空白は自動で取り除かず、全角数字も受け付けません。たとえば" 2026/02/28 "は不正入力です。一方、空白だけの値は未入力として扱います。これは今回の仕様であり、どのシステムでも空白を拒否すべきという意味ではありません。既存の画面が空白の除去や1桁の月を許しているなら、その約束を変えるかどうかを先に決めます。
年の解析はJavaのuuuuに従うので、0000/01/01も変換できます。西暦1年以降だけを許す、利用開始日より前は拒否する、といった制限は、この変換の後に業務条件として追加します。「LocalDateへ変換できた」が「業務で登録してよい」と同義にならない点にも注意してください。
形式を固定し、STRICTで存在しない日付を拒否する
修正後の変換クラスがDateInputsです。本文だけに登場する独自のパーサーや例外クラスは使いません。テストは、ここに載せたクラスをそのまま呼び出します。
package info.syachiku.samples.date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Optional;
import java.util.regex.Pattern;
public final class DateInputs {
private static final Pattern SLASH_SHAPE =
Pattern.compile("[0-9]{4}/[0-9]{2}/[0-9]{2}");
private static final DateTimeFormatter SLASH_DATE =
DateTimeFormatter.ofPattern("uuuu/MM/dd")
.withResolverStyle(ResolverStyle.STRICT);
private DateInputs() {}
public static LocalDate parseSlashDate(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("日付は必須です");
}
if (!SLASH_SHAPE.matcher(value).matches()) {
throw new DateTimeParseException(
"日付は半角数字のuuuu/MM/ddで入力してください", value, 0);
}
return LocalDate.parse(value, SLASH_DATE);
}
public static Optional<LocalDate> parseOptionalDate(String value) {
if (value == null || value.isBlank()) {
return Optional.empty();
}
return Optional.of(parseSlashDate(value));
}
}parseSlashDateは三段階で判定します。最初の分岐がnull・空文字・空白だけを必須エラーにし、次のSLASH_SHAPEが半角数字と桁数と区切りを確認します。最後のLocalDate.parseが、STRICTを指定したフォーマッターで存在日を確認します。正規表現だけで2月の日数を判定する実装にはしません。
形の確認でも日付の確認でもDateTimeParseExceptionを返すため、呼び出す側は「値を入力したが不正だった」と扱えます。必須の未入力はIllegalArgumentExceptionです。この例外の型の使い分けは本サンプルの契約で、画面向け文言までこのクラスに詰めてはいません。現場の共通エラー型があるなら、境界で変換して利用します。
value.trim()を追加する、スラッシュをハイフンへ置き換える、といった変更はSTRICTの指定とは別の仕様変更です。修正のついでに許可する入力を増減させず、今回の不具合に必要な変更と分けてレビューします。
uuuu・MM・ddとSTRICTを、別々の役割として読む
uuuu/MM/ddは、年・月・日をどの文字から読み取るかを指定します。大文字のMMが月、小文字のmmは分です。月のつもりで小文字にすると、日付として必要な情報が揃いません。
yyyyは紀元と組み合わせる年、uuuuはJavaの暦年です。今回のように紀元を入力しない値をSTRICTで解析するときはuuuuを使います。テストではyyyy/MM/ddとSTRICTだけでは正常に見える入力でも日付を確定できないことと、今回のuuuuでは成功することを確認しています。すべての既存コードのyyyyを機械的に置き換える話ではありません。
そして、withResolverStyle(ResolverStyle.STRICT)は、読み取った年・月・日を実際の日付として確定するときの方針です。桁が合うことと、2月30日を受け付けないことを、同じ指定一つで済むと思わず分けて読みます。フォーマッターは不変なので、withResolverStyleが返す値を定数へ保持する必要があります。
任意項目でも、不正な日付をemptyへ隠さない
parseOptionalDateがemptyを返すのは、null・空文字・空白だけの場合です。入力があるときはparseSlashDateへ渡すので、2026/02/30や区切りの違いは同じように拒否します。
ここでDateTimeParseExceptionをcatchしてemptyを返すと、利用者が間違えた日付まで「入力していない」として保存できます。請求日は登録できたが、支払予定日だけ気づかず消えた、という結果につながります。任意とは入れなくてもよいという意味で、入れた値の妥当性を無視してよいという意味ではありません。
呼び出す側は請求日にparseSlashDate、支払予定日にparseOptionalDateを選びます。全部をOptionalに揃えるために必須入力をemptyへ変えたり、全部を必須に揃えるために任意の未入力を例外にしたりしません。戻り値の型より前に、その項目の約束を確認します。
Spring MVCのFormやJSON APIは、このクラスを直接呼ぶ方式とは別の入力経路です。サンプルは@DateTimeFormat、Jackson、画面へのエラー再表示まで実装していません。Controllerに届く前の型変換で止まっている場合は、入口のエラーを分けて確認します。
29件のテストで、正常日・不正日・未入力を確認する
対象クラスだけでなく、テスト全文とpom.xmlも配布しています。まずZIPを展開し、READMEとpom.xmlがある場所で次を実行してください。テストに通れば、javaで最初に示した比較例を実行できます。
mvn -B test
java -cp target/classes info.syachiku.samples.date.DateParsingDemoこの版では29件、失敗0・エラー0・スキップ0です。内訳はSMARTとの比較1件、必須の正常日3件、不正な形式・存在日10件、必須の未入力5件、任意の未入力5件、任意の正常日1件、任意の不正3件、yyyyとuuuuの比較1件です。nullと空文字もそれぞれ別の実行として数えています。
テストコードの全文を開く
package info.syachiku.samples.date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class DateInputsTest {
@Test
void defaultSmartAdjustsInvalidDateButOurParserRejectsIt() {
var smart = DateTimeFormatter.ofPattern("uuuu/MM/dd");
assertEquals(ResolverStyle.SMART, smart.getResolverStyle());
assertEquals(LocalDate.of(2026, 2, 28),
LocalDate.parse("2026/02/30", smart));
assertThrows(DateTimeParseException.class,
() -> DateInputs.parseSlashDate("2026/02/30"));
}
@ParameterizedTest
@CsvSource({"2026/08/14,2026-08-14", "2024/02/29,2024-02-29",
"0000/01/01,0000-01-01"})
void requiredAcceptsExistingDates(String input, String expected) {
assertEquals(LocalDate.parse(expected), DateInputs.parseSlashDate(input));
}
@ParameterizedTest
@ValueSource(strings = {"2026/02/30", "2026/02/29", "2026/13/01",
"2026/00/01", "2026/04/31", "2026-02-28", "2026/2/28",
" 2026/02/28 ", "2026/02/28", "20260228"})
void requiredRejectsInvalidShapeOrDate(String input) {
assertThrows(DateTimeParseException.class,
() -> DateInputs.parseSlashDate(input));
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", " "})
void requiredRejectsMissingInput(String input) {
var error = assertThrows(IllegalArgumentException.class,
() -> DateInputs.parseSlashDate(input));
assertEquals("日付は必須です", error.getMessage());
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", " "})
void optionalReturnsEmptyOnlyForMissingInput(String input) {
assertEquals(Optional.empty(), DateInputs.parseOptionalDate(input));
}
@Test
void optionalReturnsExistingDate() {
assertEquals(Optional.of(LocalDate.of(2024, 2, 29)),
DateInputs.parseOptionalDate("2024/02/29"));
}
@ParameterizedTest
@ValueSource(strings = {"2026/02/30", "2026-02-28", "2026/2/28"})
void optionalDoesNotHideInvalidInput(String input) {
assertThrows(DateTimeParseException.class,
() -> DateInputs.parseOptionalDate(input));
}
@Test
void strictYearOfEraNeedsMoreInformationThanProlepticYear() {
var strictYearOfEra = DateTimeFormatter.ofPattern("yyyy/MM/dd")
.withResolverStyle(ResolverStyle.STRICT);
assertThrows(DateTimeParseException.class,
() -> LocalDate.parse("2026/08/14", strictYearOfEra));
assertEquals(LocalDate.of(2026, 8, 14),
DateInputs.parseSlashDate("2026/08/14"));
}
}たとえばdefaultSmartAdjustsInvalidDateButOurParserRejectsItは、同じ2026/02/30を二つの方針へ渡します。既定のSMARTが2月28日に補正することと、実際のDateInputsが拒否することを別々に確認しています。正常な日付が通るテストだけでは、この誤りは見つかりません。
また、optionalDoesNotHideInvalidInputは任意項目へ入った不正な値でも例外を返すことを確認します。必須側だけ直しても、任意側に古いフォーマッターやcatchしてemptyを返す処理が残っていたら、同じ約束にならないためです。
検証では、対象クラスのSTRICTをSMARTへ変えたコピーでもテストを実行しました。その誤った版は29件中5件が失敗します。テストが存在しない日付の受理を検出することを確かめ、配布するコードはSTRICTへ戻しています。失敗する版を正常サンプルへ混ぜていません。
pom.xmlの全文を開く
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>info.syachiku.samples</groupId>
<artifactId>strict-date-input</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.2.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
</project>確認環境はJava 17.0.8、Maven 3.9.5、JUnit 5.12.2です。検証時は既存の依存キャッシュを使っています。初回に必要な依存を空のキャッシュから取得する手順や、別のJDK・OSは未確認です。対象クラス本体はJava標準APIだけで、JUnitはテスト実行に使います。
レビューでは、入力の約束と失敗する値を一緒に渡す
「Formatterを付けました」だけでは、どの不具合を直したか判断できません。今回なら、2026/02/30が以前は2月28日へ変わり、修正後は登録前に拒否されることを、対象メソッドとテスト名を添えて説明します。正常な入力が引き続き使えることも必要です。
レビューコメント例:スラッシュ形式を読めるだけでなく、存在しない日付を拒否する仕様です。
DateInputsへSTRICTを設定し、2026/02/30が例外になるテストを追加してください。未入力は必須・任意で分け、任意項目の不正値をemptyにしないことも確認します。
修正を出す前に、次の五つを確認します。
- 入力仕様の区切り・桁数・許可文字・前後空白の扱いを決めたか
- 正常日、うるう日、存在しない日付で結果を確認したか
- 必須の未入力と、任意の未入力を区別したか
- 任意項目の不正入力を、未入力として隠していないか
- 本文・対象コード・テスト・ダウンロード先が同じファイルを指すか
変換を直したら、次は日付の加算結果の扱いや、画面でエラーを戻す場所を確認します。変換APIの動作と、その前後にある処理を分けて追うと、修正範囲を説明しやすくなります。
日付の変換と例外処理の基礎を学ぶ
文字列を日付として読み取る処理と、読み取れなかったときの例外処理を復習したい場合は、『スッキリわかるJava入門 第5版』の第15章と第17章が手がかりになります。任意の日付欄で、未入力はOptional.empty()、不正な日付は例外に分ける実装は、この記事のコードとテストで確認できます。
スッキリわかるJava入門 第5版
変数・条件分岐から、クラスの基本まで学ぶ一冊。
豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。
- Javaを基礎から体系的に学びたい
- Java 25 LTS対応の入門書を選びたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ:形式が合っても、日付を受け付けてよいとは限らない
スラッシュ形式を読むにはDateTimeFormatterを指定します。その既定のSMARTは、存在しない日を補正する場合があります。入力どおりの存在日だけを許可する今回の仕様では、形式を確認したうえでSTRICTを使います。
必須の未入力は拒否し、任意の未入力はemptyにする。ただし任意でも不正な日付は拒否する。この約束を対象クラスと29件のテストで揃えたものが、冒頭から取得できるサンプルです。



