rfc:nullable-not-nullable-cast-operator

PHP RFC: Nullable and Non-Nullable Cast Operators

Introduction

PHP's type casting operators currently almost always convert values to the target type even if it cannot be represented from the original value. For example null is evaluated in the following way: (int) null evaluates to 0, (string) null evaluates to "", and (bool) null evaluates to false. While this behavior is consistent with PHP's loose typing philosophy, it can mask bugs when null should be treated as a distinct absence of value.

The “almost always” caveat is because non-Stringable objects when being cast to string with (string) will throw a TypeError instead of returning an empty string.

This RFC proposes two new cast operator variants that provide explicit control over null handling and stricter validation than traditional casts:

  • Nullable cast (?type): Allows null to pass through unchanged; non-null values are validated and coerced using weak mode rules
  • Non-null cast (!type): Throws TypeError if the value is null or if it fails weak mode validation; valid non-null values are coerced to the target type

Unlike traditional casts that silently truncate malformed values (e.g., (int) "123aze" returns 123), these operators reject invalid input while still allowing safe conversions (e.g., (!int) "123" works, but (!int) "123aze" throws).

These operators complement PHP's existing nullable type declarations (?int, ?string, etc.) by providing the same semantics at the cast level, with the added benefit of input validation.

Current Behavior

Currently, PHP casts coerce null to type-specific default values:

<?php
var_dump((int) null);     // int(0)
var_dump((float) null);   // float(0)
var_dump((string) null);  // string(0) ""
var_dump((bool) null);    // bool(false)
var_dump((array) null);   // array(0) {}
?>

This behavior applies regardless of the strict_types declaration:

<?php
declare(strict_types=1);
 
var_dump((int) null);     // Still int(0)
?>

The strict_types directive only affects function parameter and return type checking, not explicit casts.

Proposal

This RFC introduces two new cast operator variants:

Nullable Cast: (?type)

The nullable cast allows null to pass through without coercion. If the value is not null, it is coerced to the specified type using weak mode function parameter coercion (stricter than traditional casts).

<?php
(?int) $value      // null -> null, others -> int
(?float) $value    // null -> null, others -> float
(?string) $value   // null -> null, others -> string
(?bool) $value     // null -> null, others -> bool
(?array) $value    // null -> null, others -> array
?>

Non-Null Cast: (!type)

The non-null cast throws a TypeError in two scenarios:

  • If the value is null: Rejected because null is not allowed
  • If weak mode validation fails: Rejected for malformed values (e.g., "123aze" cannot be converted to int)

Valid non-null values are coerced to the specified type using weak mode function parameter coercion (stricter than traditional casts).

<?php
(!int) $value      // null -> TypeError, "123" -> 123, "123aze" -> TypeError
(!float) $value    // null -> TypeError, "12.5" -> 12.5, "abc" -> TypeError
(!string) $value   // null -> TypeError, 123 -> "123", non-Stringable object -> TypeError
(!bool) $value     // null -> TypeError, valid values -> bool
(!array) $value    // null -> TypeError, valid values -> array
?>

Type Coercion Semantics

The nullable and non-null cast operators use weak mode function parameter coercion rules, not the permissive behavior of traditional explicit casts.

Mental Model

These operators behave exactly as if passing a value through a typed function parameter in weak mode (without strict_types):

<?php
// (!type) $value is equivalent to calling a function
// with typed parameter in weak mode:
function cast(type $v): type { return $v; }
$result = cast($value);  // weak mode behavior
 
// (?type) $value is equivalent to:
function cast(?type $v): ?type { return $v; }
$result = cast($value);  // weak mode behavior
?>

This means the new operators always use weak mode validation rules, even when strict_types=1 is declared (see the “Interaction with strict_types” section for details).

Important: Weak mode validation can still throw TypeError. The function parameter analogy means both the null handling AND the validation behavior match typed parameters - which means TypeError is thrown for both null (when non-nullable) and invalid values that cannot be coerced.

Key Difference from Traditional Casts

Traditional casts like (int) are maximally permissive and perform lossy conversions:

<?php
// Traditional cast - silently truncates
(int) "123aze";  // 123 (no error, data loss)
 
// Function parameter in weak mode - validates input
function test(int $i): int { return $i; }
test("123aze");  // TypeError: Cannot convert string to int
 
// New non-null cast - matches function parameter behavior
(!int) "123aze";  // TypeError: Cannot convert string to int
?>

Validation Behavior

The new operators validate inputs according to weak mode type juggling rules:

<?php
// Valid numeric strings - accepted
(!int) "123";      // 123
(!float) "12.5";   // 12.5
 
// Malformed numeric strings - rejected
(!int) "123aze";   // TypeError
(!int) "abc";      // TypeError
 
// null handling
(!int) null;       // TypeError
(?int) null;       // null
 
// Nullable cast still validates non-null values
(?int) "123";      // 123
(?int) "123aze";   // TypeError (validates when non-null)
?>

This behavior ensures that the new cast operators are stricter and safer than traditional casts while remaining more convenient than manual validation.

Syntax and Examples

Nullable Cast Examples

<?php
// Database value that might be NULL
$age = (?int) $row['age'];  // null stays null, "25" becomes 25
 
// Optional query parameter
$limit = (?int) ($_GET['limit'] ?? null);  // null if not set
?>

Non-Null Cast Examples

<?php
// Required field that must not be null
$userId = (!int) $data['user_id'];  // Throws if null or malformed
 
// API response validation
$response = json_decode($json, true);
$id = (!int) $response['id'];  // Throws if null or malformed
?>

Comparison with Current Behavior

<?php
// ===== NULL HANDLING =====
 
$value = null;
$result = (int) $value;  // 0 - may hide a bug
 
// Nullable cast - null stays null
$result = (?int) $value;  // null - preserves absence
 
// Non-null cast - null throws
$result = (!int) $value;  // TypeError - fails fast
 
// ===== MALFORMED STRING HANDLING =====
 
// Traditional cast - silently truncates (lossy)
$value = "123aze";
$result = (int) $value;  // 123 - data loss, no error
 
// New operators - reject invalid input (safe)
$result = (!int) $value;  // TypeError - cannot convert
$result = (?int) $value;  // TypeError - validates even when nullable
 
// ===== VALID STRING HANDLING =====
 
// All three work the same for valid numeric strings
$value = "123";
$result = (int) $value;   // 123
$result = (!int) $value;  // 123
$result = (?int) $value;  // 123
?>

The key difference: traditional casts are maximally permissive (silent data loss), while the new operators use weak mode validation (reject malformed input).

Behavior Comparison Table

Input Value (int) (!int) (?int) Function param int (weak mode)
"123" 123 123 123 123
"123aze" 123 ⚠️ TypeError TypeError TypeError
"abc" 0 ⚠️ TypeError TypeError TypeError
null 0 ⚠️ TypeError null TypeError
123 123 123 123 123

⚠️ = potentially unsafe (silent coercion/data loss)

The new operators (!type and ?type) align with function parameter behavior, providing safer and more predictable type coercion.

Interaction with strict_types

These cast operators always use weak mode coercion rules, regardless of the strict_types setting:

<?php
declare(strict_types=1);
 
// Valid numeric strings - coerced in both strict and weak modes
(?int) "123";      // 123 (allowed)
(!int) "123";      // 123 (allowed)
 
// Malformed strings - rejected in both strict and weak modes
(?int) "123aze";   // TypeError (rejected)
(!int) "123aze";   // TypeError (rejected)
 
// null handling - consistent in both modes
(?int) null;       // null (allowed)
(!int) null;       // TypeError (rejected)
?>

The behavior is uniform because these operators always use weak mode coercion rules, even when strict_types=1 is declared.

This differs from function parameters, which do respect strict_types:

<?php
declare(strict_types=1);
 
// Function parameter - respects strict_types (rejects string)
function test(int $i): int { return $i; }
test("123");       // TypeError in strict mode, allowed in weak mode
 
// New operators - always use weak mode rules (allows valid strings)
(!int) "123";      // 123 (allowed regardless of strict_types)
(!int) "123aze";   // TypeError (rejected regardless of strict_types)
?>

The new operators provide a middle ground: stricter than traditional casts (validate input), but more permissive than strict mode (allow valid numeric strings).

Comparison with Traditional Casts

Traditional casts like (int) are always maximally permissive regardless of strict_types:

<?php
declare(strict_types=1);
 
// Traditional cast - always permissive, even with strict_types
(int) "123aze";    // 123 (no error, data loss)
 
// New operators - always validate, regardless of strict_types
(!int) "123aze";   // TypeError (rejects malformed input)
?>

Use Cases

1. Database NULL Handling

<?php
// Traditional approach - ambiguous
$age = (int) $row['age'];  // NULL becomes 0, but 0 might be valid
 
// With nullable cast - explicit
$age = (?int) $row['age'];  // NULL stays null, distinguishable from 0
if ($age === null) {
    echo "Age not provided";
} else {
    echo "Age: $age";
}
?>

2. API Response Processing

<?php
$json = '{"count": null, "total": 100}';
$data = json_decode($json, true);
 
// Non-null cast for required fields
$total = (!int) $data['total'];  // 100
 
// Nullable cast for optional fields
$count = (?int) $data['count'];  // null
$count = $count ?? 0;  // Default to 0 if null
?>

3. Form Input Validation

<?php
// Required field
$quantity = (!int) ($_POST['quantity'] ?? null);  // Throws TypeError if missing
 
// Optional field
$discount = (?float) ($_POST['discount'] ?? null);  // null if missing
$discount = $discount ?? 0.0;  // Apply default
?>

4. Type-Safe Wrapper Functions

<?php
function getConfigInt(string $key): ?int {
    $value = getenv($key);  // Returns string|false
    if ($value === false) {
        return null;  // Config not set
    }
    return (!int) $value;  // Validate and convert to int
}
 
function requireConfigInt(string $key): int {
    $value = getenv($key);  // Returns string|false
    if ($value === false) {
        throw new RuntimeException("Missing config: $key");
    }
    return (!int) $value;  // Validate and convert to int
}
?>

5. Reducing Boilerplate

<?php
// Before - verbose null checking
$value = $input['value'] ?? null;
if ($value !== null) {
    $value = (int) $value;  // Permissive, may silently truncate
}
 
// After - concise and validates input
$value = (?int) ($input['value'] ?? null);
?>

Comparison Table

Cast Type Syntax null Input Valid String ("123") Malformed String ("123aze")
Current cast (int) $value 0 ⚠️ 123 123 ⚠️ (silently truncates)
Nullable cast (?int) $value null 123 TypeError (validates input)
Non-null cast (!int) $value TypeError 123 TypeError (validates input)

Key differences:

  • Current casts: Maximally permissive - never throw, may lose data
  • New operators: Validate input using weak mode rules - reject malformed values while accepting valid conversions

Supported Types

Both ? and ! operators support all existing cast types:

  • (?int) / (!int) - Integer cast
  • (?float) / (!float) - Float cast
  • (?string) / (!string) - String cast
  • (?bool) / (!bool) - Boolean cast
  • (?array) / (!array) - Array cast
  • (?object) / (!object) - Object cast

Backward Incompatible Changes

This proposal introduces new syntax and has no backward compatibility issues:

  • The syntax (?type) and (!type) are currently parse errors
  • No existing code uses these patterns
  • No changes to existing cast behavior
  • No impact on strict_types semantics

Proposed PHP Version

PHP 8.6

Voting Choices

This is a simple yes/no vote requiring a 2/3 majority.

Accept nullable and non-nullable cast operators for PHP 8.6?
Real name Yes No
Final result: 0 0
This poll has been closed.

Vote will start on [TBD] and end on [TBD].

References

rfc/nullable-not-nullable-cast-operator.txt · Last modified: by nicolasgrekas