rfc:check-operator

PHP RFC: Check Operator

Introduction

PHP is traditionally reliant on exception-based error handling.

In recent years, the PHP type system has evolved and improved considerably, while error handling has not.

Many modern programming languages, frameworks and libraries, are moving towards functional programming paradigms, and (especially) towards more explicit error handling models that treat errors as first-class values, enabling more intentional and granular error handling.

This RFC proposes the introduction of a check operator: a language-level mechanism to conditionally throw when the returned value from a function call yields a Throwable type.

Overview

The check operator is designed to be used in conjunction with return type unions - the following is a small, motivating example demonstrating its use:

function divide(int $a, int $b): int|DivisionByZeroError {
    if ($b == 0) {
        return new DivisionByZeroError("Cannot divide by zero");
    }
    return $a / $b;
}
 
// Normal usage - does not throw exceptions:
 
$result = divide(10, 0);
 
if (is_int($result)) {
    // do something
} else {
    // handle error
}
 
// Using the `check` operator to declaratively throw:
 
$result = check divide(10, 0);  // Throws if a Throwable is returned

Proposal

The check operator is a right-associative operator, which applies to function and method calls, e.g.:

$a = check myFunction();
$b = check $myObject->myMethod();

The check operator checks the return value from the function call, and evaluates the result, such that:

  1. If the return value is a Throwable, the check expression performs a throw (effectively yielding never)
  2. If the return value is not a Throwable, the check expression evaluates to the return value of the function.

Traditionally, exceptions of any type can be thrown anywhere in the call stack, making it difficult to reason about where, how, and what type of errors might occur. The check operator encourages functions with better locality of behavior, and precise type declarations which can be checked (by existing type checking features) at run-time.

The check operator offers an alternative to throwing exceptions in functions, enabling the call site to explicitly define, or declaratively control, the error handling behavior.

In addition, the check operator provides better support for static analysis in tools and IDEs, by enabling the removal of any Throwable types (from a return type union) during static analysis, improving type inference of local variables.

Stack Trace Behavior

The call stack of an implied throw, when generated by the check operator, begins at the call site where the check operator was applied.

(Note that, in PHP, exceptions are not populated unless they are thrown - the stack trace and line number of an exception produced by a function call without a check operator do not get populated.)

Impact on Existing Code

Many existing codebases have static factory methods for creating errors - these can be directly leveraged:

class ValidationError extends RuntimeException
{
    public static function invalidInput(string $field): self
    {
        return new self("Invalid input for field: $field");
    }
}
 
function validateUser(array $data): User|ValidationError
{
    if (empty($data['name'])) {
        return ValidationError::invalidInput('name');
    }
    return new User($data);
}
 
// Usage in a controller:
 
$result = validateUser($data);
 
return match(true) {
    $result instanceof User
        => new JsonResponse(200, ["user_id" => $result->getUserID()]),
    $result instanceof ValidationError
        => new JsonResponse(500, ["error" => $result->getMessage()]),
    default
        => check $result // unhandled error type, generates an exception
}
 
// Usage in a command, service, background process etc.:
 
$user = check validateUser($data);  // Throws if ValidationError

Note that, while the check operator enables the use of return type unions for exception-free error reporting, it has impact on existing code that throws exceptions - this feature does not alter the behavior of exceptions, but instead motivates the use of more explicit error unions in return types, which itself motivates the use of local and immediate error handling.

Wrapping Unsafe Code

The check operator has no impact on existing code that throws, but can be used to with existing try-catch error handling - for example, here we wrap an unsafe function call in a type-safe function:

function safeOperation(): int|ProcessException {
    try {
        return riskyOperation();
    } catch (ProcessException $e) {
        return $e; // return instead of throwing
    }
}
 
$result = check safeOperation();

This approach provides local control of the error handling behavior.

Performance Implications

The check operator itself is expected to have a small type-checking overhead - the feature should have no negative performance impact on anything else.

(In some cases, the use of check might provide some minor performance improvement, by avoiding unnecessary capture or propagation of stack frames, in cases where expected error conditions can be handled locally - however, this is not a primary motivation for this feature.)

Impact on the error suppression operator

The @ error suppression operator, when applied to a function call in a check expression (as in e.g. $user = check @loadUser(1)) may suppress warnings or notices, altering the code path within that function.

This has no bearing on the behavior of the check operator itself, which only handles Throwable values - if the function call with error suppression evaluates to null, that value will pass through as the result of the expression.

No impact on error handling

The check operator is means of generating exceptions, not a means of handling them.

If the check operator is applied to a function that throws an exception, the function call never returns, and so the check operator does not interfere with normal error propagation.

Besides potentially generating an implicit throw, the check operator has no influence on the behavior of try/catch blocks - exceptions generated by the check operator behave exactly like those generated by a throw statement.

Use of the check operator does not interfere with the set_error_handler or set_exception_handler.

The check operator does not interfere with type-hint enforcement - a function that returns something invalid (according to its return type) will trigger an error, as usual.

Backward Incompatible Changes

This feature is fully backwards compatibility with existing code, and can be incrementally adopted, offering a path towards more robust and type-safe error handling in many scenarios, without wholesale rewrites.

Unaffected PHP Functionality

This feature has no impact on existing exception behavior.

Proposed Voting Choices

For this proposal to be accepted, a 2/3 majority is required.

Patches and Tests

No patch has been written for this RFC.

rfc/check-operator.txt · Last modified: 2024/12/17 15:31 by mindplay