In my last blog post, I wrote about strict types and why they’re so important to use. In this blog post, I’ll continue writing about types. However, this time I’m going to write about functions and what types they should return.
When writing functions it’s often easy to determine what type of data the function should return when it executes successfully. But when the function fails, what would be the most appropriate return type?
You might reason that it depends on the function, but that might not be completely true. Unifying the types your functions return actually makes a lot of sense when you look into what different values and types mean. Also, unifying your return types throughout your functions will make it much easier for yourself and others to use code that you have written. Let’s dig a bit deeper.
Value has a value, value is empty, value is null
Let’s divide the return values into three categories; return has a value, return has an empty value, return does not have a value.
// has value
$string = 'value';
$integer = 1;
$array = ['value'];
$boolean = true;
// empty value
$string = '';
$integer = 0;
$array = [];
$boolean = false;
// no value
$string = null;
$integer = null;
$array = null;
$boolean = null;
So when your function executes successfully it makes sense to return a value for the given return type. However, when the logic in the function bumped into something unexpected and is returning an error, what type should you use then?
I frequently see functions that return false
in case of an error but if you’re expecting a string as a return value, it’s not correct to return a value with another type.
// bad example
function transformString(string $string): string|bool {
if (! strlen($string)) {
return false;
}
return $string; // do something with $string
}
As you see, you’re now returning a union type which is unnecessary. It would be better to just return one type and return an empty value in this case.
// good example
function transformString(string $string): string {
if (! strlen($string)) {
return '';
}
return $string; // do something with $string
}
How about null
then? Null essentially means that there is supposed to be a value but that value doesn’t exist. An example of this would be when you fetch something from a database and the value is not available.
function fetchCustomerFromDb($customerId): ?Customer {
$customer = Customer::find($customerId);
if (is_null($customer)) {
return null;
}
return $customer;
}
Instead of null, return a union type
So returning null
has its use cases, but when you’re writing a feature class or a function with more logic than in our previous example, it is many times, not the best option. The reason for this is that it doesn’t give you any information about what went wrong, making it difficult to debug your code.
In this case, it’s better to return a union type, a combination between a success value type and a failed value type that contain information about the error that occurred.
Let me illustrate with an example, in this feature class, we’re fetching a token that is used for an external API. My preferred way is to either return a custom ExecutionFailed
class containing information about what went wrong or simply return an Exception with an exception code.
The exception doesn’t get thrown inside the feature class. Instead, it leaves it up to the consuming class to determine if it wants to throw the exception or not.
class FetchToken()
{
public const accessTokenNotFound = 0;
public const connectionError = 1;
public function execute(): string|Exception
{
if ($accessTokenNotFound) {
$message = 'Could not find access token';
return new Exception($message, static::accessTokenNotFound);
}
if ($connectionError) {
$message = 'Failed to establish connection with API';
return new Exception($message, static::connectionError);
}
return $accessToken;
}
}
Now in your consumer class, you know more about what could have gone wrong when trying to fetch the token. You could then handle the error in your consumer class like this.
if ($result instanceof Exception) {
$errorMessage = match ($result->getCode()) {
FetchToken::accessTokenNotFound => trans('error.token_not_found'),
FetchToken::connectionError => trans('error.connection_error'),
default => trans('error.generic_error')
};
return response()->json([$errorMessage], 400);
}
return response()->json([trans('success')]);
The error codes in the feature class can of course be extended to include more error cases if needed. This is very useful when your feature class has more advanced logic and multiple conditional statements.
That said, if your feature class only has simple logic and only returns one type of data then it can be a bit overkill to use union return types like in this example. In that case, it makes more sense to only return an empty value of the same type.
I hope that this was an interesting read, I had a lot of fun putting this article together.
Until next time, have a good one!