Repository navigation
Infer literal types for the a, A, S, D, l, M, and F date format characters #15537
Description
Feature request
This issue came to light while improving PHPStan types in WordPress core: WordPress/wordpress-develop#14827.
DateFunctionReturnTypeHelper::buildReturnTypeFromFormat() returns precise unions for the numeric single-character formats (d, j, N, w, m, n, t, L, g, G, h, H, I), added in phpstan/phpstan-src#721. The single-character formats that produce words instead of numbers fall through to the date() fallback, which only yields non-falsy-string. However, these formats also have a small, fixed set of possible values, since date(), DateTimeInterface::format(), and date_format() always output English regardless of the locale:
| Format | Values |
|---|---|
a |
'am'|'pm' |
A |
'AM'|'PM' |
S |
'st'|'nd'|'rd'|'th' |
D |
'Mon'|'Tue'|'Wed'|'Thu'|'Fri'|'Sat'|'Sun' |
l |
'Monday'|'Tuesday'|'Wednesday'|'Thursday'|'Friday'|'Saturday'|'Sunday' |
M |
'Jan'|'Feb'|'Mar'|'Apr'|'May'|'Jun'|'Jul'|'Aug'|'Sep'|'Oct'|'Nov'|'Dec' |
F |
'January'|'February'|'March'|'April'|'May'|'June'|'July'|'August'|'September'|'October'|'November'|'December' |
These values do not depend on the locale. PHP formats these characters from hard-coded English tables in ext/date/php_date.c (mon_full_names, mon_short_names, day_full_names, day_short_names, and english_suffix(), and the a/A cases), and setlocale() is never consulted. For example, after setlocale( LC_ALL, 'fr_FR.UTF-8' ) (or de_DE, ja_JP, or ru_RU), date( 'a A S D l M F' ) still returns pm PM nd Mon Monday Mar March, while strftime( '%p %a %A %b %B' ) returns localized names such as Lundi and mars. Localized output requires IntlDateFormatter (or the deprecated strftime()), so typing these formats as fixed English unions is safe.
Without these, passing the result to a function that accepts only the possible values is reported as an error, even though it cannot fail. For example, WordPress core translates the meridiem with WP_Locale::get_meridiem( $datetime->format( 'a' ) ), and that method's parameter is 'am'|'pm'|'AM'|'PM'. Similarly, the English weekday and month names are natural lookup keys for translation tables.
Playground snippet: https://phpstan.org/r/3bb27011-c9c1-4d7f-8a18-50651615a329
<?php declare(strict_types = 1);
use function PHPStan\Testing\assertType;
/**
* @param 'am'|'pm'|'AM'|'PM' $meridiem
*/
function translate_meridiem( string $meridiem ): string {
return $meridiem;
}
function test( DateTimeImmutable $datetime, int $timestamp ): void {
echo translate_meridiem( $datetime->format( 'a' ) );
echo translate_meridiem( date( 'A', $timestamp ) );
assertType( "'am'|'pm'", $datetime->format( 'a' ) );
assertType( "'AM'|'PM'", $datetime->format( 'A' ) );
assertType( "'nd'|'rd'|'st'|'th'", $datetime->format( 'S' ) );
assertType( "'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", $datetime->format( 'D' ) );
assertType( "'Friday'|'Monday'|'Saturday'|'Sunday'|'Thursday'|'Tuesday'|'Wednesday'", $datetime->format( 'l' ) );
assertType( "'Apr'|'Aug'|'Dec'|'Feb'|'Jan'|'Jul'|'Jun'|'Mar'|'May'|'Nov'|'Oct'|'Sep'", $datetime->format( 'M' ) );
assertType( "'April'|'August'|'December'|'February'|'January'|'July'|'June'|'March'|'May'|'November'|'October'|'September'", $datetime->format( 'F' ) );
assertType( "'am'|'pm'", date( 'a', $timestamp ) );
assertType( "'Friday'|'Monday'|'Saturday'|'Sunday'|'Thursday'|'Tuesday'|'Wednesday'", date_format( $datetime, 'l' ) );
}Current output (PHPStan 2.3.2, level 10):
13: Parameter #1 $meridiem of function translate_meridiem expects 'AM'|'am'|'PM'|'pm', non-falsy-string given.
14: Parameter #1 $meridiem of function translate_meridiem expects 'AM'|'am'|'PM'|'pm', non-falsy-string given.
16: Expected type 'am'|'pm', actual: non-falsy-string
17: Expected type 'AM'|'PM', actual: non-falsy-string
18: Expected type 'nd'|'rd'|'st'|'th', actual: non-falsy-string
19: Expected type 'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed', actual: non-falsy-string
20: Expected type 'Friday'|'Monday'|'Saturday'|'Sunday'|'Thursday'|'Tuesday'|'Wednesday', actual: non-falsy-string
21: Expected type 'Apr'|'Aug'|'Dec'|'Feb'|'Jan'|'Jul'|'Jun'|'Mar'|'May'|'Nov'|'Oct'|'Sep', actual: non-falsy-string
22: Expected type 'April'|'August'|'December'|'February'|'January'|'July'|'June'|'March'|'May'|'November'|'October'|'September', actual: non-falsy-string
24: Expected type 'am'|'pm', actual: non-falsy-string
25: Expected type 'Friday'|'Monday'|'Saturday'|'Sunday'|'Thursday'|'Tuesday'|'Wednesday', actual: non-falsy-string
Expected output: No errors.
The change would be seven additional case branches in buildReturnTypeFromFormat(), each returning a union of ConstantStringType.
🤖 Issue drafted with support from Claude Opus 5.5.
Did PHPStan help you today? Did it make you happy in any way?
Every day!
Activity