Skip to content

Commit 9307706

Browse files
committed
A typed class constant keeps the value it was given
1 parent bad25d1 commit 9307706

7 files changed

Lines changed: 54 additions & 40 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
122122

123123
### Fixed
124124

125+
- **A typed class constant keeps the value it was given.** PHP 8.3 lets a class constant declare a type (`private const int DEFAULT_OPTIONS = JSON_HEX_TAG | JSON_THROW_ON_ERROR;`), and PHPantom took that declaration as the whole answer, so everything the initialiser said was thrown away the moment a type was written in front of it. Only the declared type reached the code that reads a constant's value, which is what decides a flag argument, a match subject, and a comparison against a constant. The everyday symptom was a `json_encode($value, self::DEFAULT_OPTIONS)` reported as `string|false` when the mask it is handed sets `JSON_THROW_ON_ERROR`, a failure that throws rather than returns. The initialiser is now read the same way an untyped constant's is, and the declared type is what stands when the value cannot be worked out. A declaration that says more than its initialiser does keeps its own answer, so a constant typed as an enum still resolves to that enum rather than to the case it happens to hold.
125126
- **An `&&` or `||` guard narrows the operand beside it wherever the check is written.** The right-hand side of `&&` runs only when the left-hand side held, and the right-hand side of `||` only when it did not, so a check on the left says something about the value the right reads. PHPantom only drew that conclusion when the whole check was an `if`, `while`, or `for` condition or a `return` value. Written anywhere else the guard proved nothing, and the operand beside it was read at the type the guard had just ruled out. That covers most of the places these are written: assigned to a variable (`$ok = is_string($v) && strlen($v) > 0;`), passed as an argument, put in an array, used as a ternary's condition, or nested inside a larger check. A guard now narrows the operands that follow it in every position, so the false type errors those forms produced are gone. It stays a statement about the operands: nothing after the check the guard sits in is affected, exactly as before.
126127
- **An autoloader's closure parameter is a string.** The class name PHP hands an autoloader is the only thing it is ever called with, but the stub for `spl_autoload_register()` promises no more than a `callable`, so `spl_autoload_register(function ($class) { … })` left `$class` with no type at all. Every string builtin applied to it then answered for an argument of any type: `str_replace('App\\', '', $class)` came back as `array|string`, and building the file path out of it was reported as a type error in the one place the value is guaranteed to be a string. The callback is now typed the way PHP calls it. Registering the default autoloader by passing nothing, a function name, or `null` is unaffected.
127128
- **`ReflectionClass::newInstanceArgs()` returns an instance rather than a maybe-instance.** It builds the same object `newInstance()` does, and throws when it cannot, but the stubs still carry the nullable return type PHP 5 gave it. A method returning `$reflection->newInstanceArgs([$arg])` was therefore reported for returning `Node|null` where it declares `Node`, and the same call written as `newInstance($arg)` was fine. Both now resolve to the instance type, so neither asks for a null check against a null that cannot arrive.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ contributor even though it's short.
3535
| # | Item | Impact | Complexity |
3636
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
3737
| B51 | [`instanceof` does not eliminate a class from a union in the guarded branch](todo/bugs.md#b51-instanceof-does-not-eliminate-a-class-from-a-union-in-the-guarded-branch) | High | High |
38-
| B57 | [A typed class constant loses its literal value](todo/bugs.md#b57-a-typed-class-constant-loses-its-literal-value) | Medium | Medium |
3938
| B75 | [A builtin's return type is not derived from the arguments actually passed](todo/bugs.md#b75-a-builtins-return-type-is-not-derived-from-the-arguments-actually-passed) (six sub-cases, one PR) | Medium | Medium-High |
4039
| B58 | [The array union operator (`+`) does not preserve a previously narrowed key type](todo/bugs.md#b58-the-array-union-operator--does-not-preserve-a-previously-narrowed-key-type) | Medium | High |
4140
| B62 | [PHPUnit's `assertNotNull`/`assertNotFalse` are not recognized as narrowing assertions](todo/bugs.md#b62-phpunits-assertnotnullassertnotfalse-are-not-recognized-as-narrowing-assertions) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,35 +27,7 @@ No outstanding items.
2727

2828
## Type comparison
2929

30-
### B57. A typed class constant loses its literal value
31-
32-
**Impact: Medium · Complexity: Medium**
33-
34-
```php
35-
final class Json
36-
{
37-
private const int DEFAULT_OPTIONS = JSON_HEX_TAG | JSON_THROW_ON_ERROR;
38-
39-
public static function encode(mixed $value, int $options = 0): string
40-
{
41-
return json_encode($value, $options | self::DEFAULT_OPTIONS); // reported: got string|false
42-
}
43-
}
44-
```
45-
46-
`json_encode()`'s return type narrows to plain `string` (dropping
47-
`false`) when the resolved `$flags` argument provably has
48-
`JSON_THROW_ON_ERROR` set — the flag makes a failure throw instead of
49-
returning `false`. That bitwise fold works when `DEFAULT_OPTIONS` is a
50-
plain untyped `const`, but PHP 8.3's typed class constants
51-
(`const int NAME = expr`) resolve to the declared type (`int`) instead
52-
of the constant-folded literal value of `expr`, so the downstream
53-
bitwise-OR reasoning never sees `JSON_THROW_ON_ERROR` and falls back to
54-
the unrefined `string|false`.
55-
56-
**Fix:** when reading a typed class constant's value for constant
57-
folding, evaluate its initializer expression the same way an untyped
58-
constant's is evaluated, rather than substituting the declared type.
30+
No outstanding items.
5931

6032
## Standard-library return types
6133

examples/php/completion.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,9 @@ class ConditionalReturnDemo
14071407
/** And so is one built out of several. */
14081408
public const JSON_COMBO = JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR;
14091409

1410+
/** A declared type says what the constant may hold, not what it holds. */
1411+
public const int JSON_TYPED = JSON_HEX_TAG | JSON_THROW_ON_ERROR;
1412+
14101413
public function demo(): void
14111414
{
14121415
$container = new Scaffolding\Container();
@@ -1529,6 +1532,8 @@ public function demo(): void
15291532
$mask = JSON_UNESCAPED_SLASHES | self::JSON_FLAGS;
15301533
$fromMask = json_encode(['ok' => true], $mask);
15311534
strtoupper($fromMask); // mask in a variable → string
1535+
$typed = json_encode(['ok' => true], self::JSON_TYPED);
1536+
strtoupper($typed); // typed constant → string
15321537

15331538
// More builtins whose shape an argument decides. Only the
15341539
// all-elements form of `pathinfo()` returns the component array;

examples/php/scaffolding/assertions.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,8 @@ function runDemoAssertions(): void
753753
assert(json_encode(['ok' => true], ConditionalReturnDemo::JSON_FLAGS) === '{"ok":true}', 'the flag counts when it is reached through a constant');
754754
$jsonMask = JSON_UNESCAPED_SLASHES | ConditionalReturnDemo::JSON_FLAGS;
755755
assert(json_encode(['url' => 'a/b'], $jsonMask) === '{"url":"a/b"}', 'a mask kept in a variable sets the same bits');
756+
assert((ConditionalReturnDemo::JSON_TYPED & JSON_THROW_ON_ERROR) !== 0, 'a declared type does not replace the value the constant holds');
757+
assert(json_encode(['ok' => true], ConditionalReturnDemo::JSON_TYPED) === '{"ok":true}', 'the flag counts when the constant is typed');
756758

757759
// ── More argument-decided builtins ───────────────────────────────────
758760
assert(is_array(pathinfo('/tmp/report.csv')), 'the all-elements form returns the component array');

src/type_engine/call_resolution/return_types.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1999,20 +1999,30 @@ pub(super) fn resolve_static_access_type(text: &str, ctx: &ResolutionCtx<'_>) ->
19991999
ctx.resolved_class_cache,
20002000
);
20012001
if let Some(constant) = merged.constants.iter().find(|c| c.name == _member) {
2002-
// Typed class constant — use its declared type.
2003-
if let Some(ref hint) = constant.type_hint {
2004-
return Some(hint.clone());
2005-
}
2006-
// Untyped constant — infer the value type from the initializer
2007-
// so template params bind to the constant's value (e.g. `int`)
2008-
// rather than the owning class.
2002+
// Infer the value type from the initializer so template params bind
2003+
// to the constant's value (e.g. `int`) rather than the owning class.
2004+
//
2005+
// A declared type (PHP 8.3's `const int NAME = …`) says what the
2006+
// constant may hold, not what it does hold, so the initialiser is
2007+
// still the sharper answer and is read first. It only stands in for
2008+
// the declaration when it refines it: an initialiser naming an enum
2009+
// case resolves to the case's class, which the structural check
2010+
// rejects, leaving the declared type as before.
20092011
if let Some(ref val) = constant.value {
2010-
if let Some(ty) =
2012+
let inferred =
20112013
crate::type_engine::variable::rhs_resolution::infer_type_from_constant_value(val)
2012-
{
2014+
.or_else(|| folded_class_constant_type(&merged, _member, val, ctx));
2015+
if let Some(ty) = inferred.filter(|ty| {
2016+
constant
2017+
.type_hint
2018+
.as_ref()
2019+
.is_none_or(|hint| ty.is_subtype_of(hint))
2020+
}) {
20132021
return Some(ty);
20142022
}
2015-
return folded_class_constant_type(&merged, _member, val, ctx);
2023+
}
2024+
if let Some(ref hint) = constant.type_hint {
2025+
return Some(hint.clone());
20162026
}
20172027
}
20182028

tests/integration/diagnostics_type_errors.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9931,6 +9931,31 @@ class Encoder {
99319931
assert!(messages.is_empty(), "got {messages:?}");
99329932
}
99339933

9934+
/// A declared type on the constant (PHP 8.3 typed class constants) does not
9935+
/// hide the value behind it: `const int FLAGS = …` still folds to the mask its
9936+
/// initialiser computes.
9937+
#[test]
9938+
fn json_encode_reads_throw_on_error_through_a_typed_constant() {
9939+
let php = r#"<?php
9940+
function useString(string $value): void {}
9941+
9942+
class Encoder {
9943+
private const int DEFAULT_OPTIONS = JSON_HEX_TAG | JSON_THROW_ON_ERROR;
9944+
private const int ALIAS = self::DEFAULT_OPTIONS;
9945+
9946+
public function test(mixed $value, int $options): void {
9947+
useString(json_encode($value, self::DEFAULT_OPTIONS));
9948+
useString(json_encode($value, self::ALIAS));
9949+
useString(json_encode($value, $options | self::DEFAULT_OPTIONS));
9950+
$mask = JSON_UNESCAPED_SLASHES | self::DEFAULT_OPTIONS;
9951+
useString(json_encode($value, $mask));
9952+
}
9953+
}
9954+
"#;
9955+
let messages = type_error_messages(&collect_with_full_stubs(php));
9956+
assert!(messages.is_empty(), "got {messages:?}");
9957+
}
9958+
99349959
/// A constant defined in terms of itself has no value to fold, and folding it
99359960
/// must terminate rather than chase the cycle.
99369961
#[test]

0 commit comments

Comments
 (0)