Best Practice With Return Types In PHP

23rd July 2018

I've been using PHP for a number of years and have seen the same things being done with return values from functions over and over again. I have always thought of this as pretty standard, but the more I think about it the less it makes sense. Looking back over my career I am quite sure that a few serious bugs could have been avoided if I had not mixed return types.

As PHP is a loosely typed language this gives the developers the ability to change the type of value that is returned from a function. This happens quite often within the PHP codebase itself as many built in functions will return false if an error happened.

A common practice in userland code is to return false from a function if something went wrong. This might be because it is encouraged in PHP itself.

An example here would be loading things out of a database. What happens is that if the database can't be reached, or there is an error of some kind, then the function returns false. If everything worked out then the data is returned. The code below represents this.

  1. function loadFromDatabase($db)
  2. {
  3. $result = mysqli_query($db, 'SELECT * FROM thing;');
  4.  
  5. if ($result) {
  6. return false;
  7. }
  8.  
  9. $data = [];
  10.  
  11. while ($row = $result->fetch_assoc())
  12. {
  13. $data[] = $row;
  14. }
  15.  
  16. return $data;
  17. }

At face value it makes sense to return false if something didn't go right. However, what we are doing here is passing problems and complexity up stream. To use this function we need to be aware that different types of data might be returned. As such we need to detect if false was returned, or if an empty result set was returned before we start processing the data.

  1. $db = mysqli_connect($host, $user, $pass, $database);
  2. $result = loadFromDatabase($db);
  3.  
  4. if ($result === false) {
  5. // Error encountered.
  6. }
  7.  
  8. if (count($result) == 0) {
  9. // No results found.
  10. }
  11.  
  12. // Process data.

This might seem simple stuff, but I can't tell you how many times I have encountered bugs in code caused by not properly detecting the return type. This can be simple stuff like attempting to loop over a false value but can manifest itself in more subtle ways.

For example, lets say that we wanted to process a date and if the date isn't valid then we return false. The problem is that if we don't happen to catch an invalid date then we don't know if the function failed because of an invalid date or that strtotime() produced a false response.

Additionally, if we don't detect the false response correctly then we can easily attempt to create a date with a 0 (i.e. 'false' cast as an integer) value for the timestamp. I'm sure you have seen the date 1st Jan 1970 many times on the internet, and it is generally created from problems like this.

This situation is the same in the database query code above. With a false in hand we can only guess that something bad happened in the database layer, but not exactly what happened.

This problem can be solved in a couple of ways.

Throw Exceptions

Instead of returning false a much better approach is to throw an exception. This way, instead of being unsure about why the function returned false you can deal with the outcome in a much more refined (and predicable) way.

Taking the loadFromDatabase() function again we could adapt that by defining an exception class and then throwing that exception when an error happens.

  1. class DatabaseErrorException extends Exception {}
  2.  
  3. function loadFromDatabase($db)
  4. {
  5. $result = mysqli_query($db, 'SELECT * FROM thing;');
  6.  
  7. if ($result === false) {
  8. throw new DatabaseErrorException();
  9. }
  10.  
  11. $data = [];
  12.  
  13. while ($row = $result->fetch_assoc())
  14. {
  15. $data[] = $row;
  16. }
  17.  
  18. return $data;
  19. }

The code that uses this can then be re-written cat catch this exception.

  1. $db = mysqli_connect($host, $user, $pass, $database);
  2.  
  3. try {
  4. $result = loadFromDatabase($db);
  5. // Process the data.
  6. } catch (DatabaseErrorException $e) {
  7. // Error encountered.
  8. }

This single change means that if the database threw an error then we can be absolutely certain that this has happened. Not only that but we can prevent the code from ever trying to process the data when the data doesn't exist in the first place. When the exception is thrown in the loadFromDatabase() function none of the rest of the code in the try block is run so we can be sure that we won't be in half processed state. Obviously a single exception won't tell us what happened with any detail, but this is just a simple example.

Throwing exceptions is a good idea, but like many things it can be overused. You can easily find yourself in a situation where your application crashes because you have thrown an exception and not caught it correctly. For that reason you should always make sure that your code catches any and all exceptions that might be thrown. It does provide great feedback on what your application is doing and can be used to break out of a function without using random types in your returns.

Setting Return Types

In addition to throwing exceptions we can also set the return type. PHP 7 added a language feature that allows developers to stipulate what type of value is returned from a function. Using this feature means that we can enforce that a function returns an integer by enforcing this at a language level.

The following types are supported.

  • int
  • float
  • bool
  • string
  • interfaces
  • array
  • callable
  • object (since PHP 7.2)

To implement this feature you need to add two things to your code.

Right at the top of the PHP file you need to include a declaration that tells PHP to interpret the file using the enforced return types. Without this in place the code won't throw a syntax error, but it won't enforce return types. This feature allows you to turn on and off the return type detection with a simple change.

declare(strict_types=1);

It's important to note that this declaration only effects the code in the file is was declared in. This is to prevent external libraries accidentally being effected by this change.

With that in place you can now define functions in the following way.

  1. function returnOne(): int {
  2. return 1;
  3. }

This function definition states that this function must return an integer. If it doesn't then we would get an error like this.

PHP Fatal error:  Uncaught TypeError: Return value of returnOne() must be of the type integer, string returned.

The error that PHP throws here can be caught as a TypeError exception. This allows us to potentially adapt to functions returning the wrong type.

  1. function returnWrongType(): int {
  2. return false;
  3. }
  4.  
  5. try {
  6. returnWrongType();
  7. } catch (TypeError $e) {
  8. echo 'Wrong type found.';
  9. }

As of PHP 7.1 you can specify a nullable return type with a question mark in front of the return type. This means that you can either return an integer or a null value. Note that not returning anything also technically means returning a null value.

  1. function mightReturnInt() : ?int {
  2.   return null;
  3. }

With return types and exceptions we can then generate functions with very predictable implementations that are easier to interface with. Not only that, but testing predictable functions becomes a lot easier.

Comments

Permalink

Nice explaination :) Thanks :)

Danial (Thu, 06/18/2020 - 10:59)

Permalink

Fatal error: Uncaught TypeError: Return value must be of the type string or null, none returned.

michel (Fri, 07/24/2020 - 13:06)

Add new comment

The content of this field is kept private and will not be shown publicly.