Introduction
As a freelance web developer, I'm really lucky and get the chance to work on a lot of exciting projects. The majority of the time, the projects that I work on are existing projects and I'm brought on board to help add extra functionality, fix bugs and generally help maintain the system.
As a result of working on a lot of projects, I get to meet some really cool people and teams (really fun!). I also get the chance to encounter a lot of bugs (not so fun!).
I tend to find that a lot of the bugs I encounter could have been solved with better type safety. So, as a proof-of-concept to test an idea, I put together the Type Safe package that you can use to add better type safety to your PHP projects.
In this article, we're going to take a quick look at what type safety is, why I made the pacakge, what it actually does, and when you might want to use it.
What is Type Safety?
Rather than me delve deep into what type safety is and it's benefits, I'd highly recommend reading PHP or Type Safety: Pick any two by Matt Brown. The article gives a detailed and insightful breakdown into how you can start to use some form of type safety in PHP using Psalm.
But, just to quickly recap parts of what's already covered in that article, type safety can be defined as:
"Type safety measures the ability of available language tooling to help avoid type errors when running code in a production environment."
To get some context into what type safety is in PHP, let's first look at how we can make our code more type safe. In PHP, we can define functions like so:
1function addNumbers($numberOne, $numberTwo)2{3 return $numberOne + $numberTwo;4}
In the above code block, we have created a function called addNumbers
that adds two numbers together and then returns the results. However, this method lacks any type hints for the two parameters, so we could potentially pass anything we wanted to the function, such as strings, like so:
1addNumbers('hello', 'goodbye');
As you can imagine, if we were to run this, a TypeError
will be thrown with the message Unsupported operand types: string + string
because we can't add two strings together as if they were numbers.
So, to prevent these errors, we can update our function to only allow integers to be passed as the two parameters:
1function addNumbers(int $numberOne, int $numberTwo)2{3 return $numberOne + $numberTwo;4}
Now, if we were to try and call this function again and pass 'hello'
and 'goodbye
', PHP will throw TypeError
s telling us that the parameters must be integers. As a result of doing this, we can be sure that whenever we are working inside the function that the parameters passed in are both integers.
As well as this, we can also define return types for methods so that we enforce the returned data's type. For example, we could update the addNumbers
method to only return integers:
1function addNumbers(int $numberOne, int $numberTwo): int2{3 return $numberOne + $numberTwo;4}
Why Did I Make the Package?
I decided to put together the Type Safe package purely as a proof-of-concept to scratch an itch of mine. I don't really expect the code in this package to ever be used in a production environment, but there are a few times in the past where something like this could have come in handy for me.
1. Incorrect Method Docblocks
As I've already mentioned above, I work on a lot of projects. The majority of the time, the projects that I work on are existing projects and I'm brought on board to help add extra functionality, fix bugs and generally help maintain the system.
One thing that I find a common occurence in these projects are outdated docblocks. I think that as projects grow and change, documentation and docblocks are things which can get left behind. And let's be honest, it's because maintaining them can be pretty dull.
For example, let's take this method:
1/** 2 * @return int[] 3 */ 4public function build(): array 5{ 6 // Do something and build an array called $data... 7 // $data is equal to: [1, 2, 3]. 8 9 return $data;10}
When we look at this build
method, we can see from the docblock that it is supposed to return an array of integers. However, it's better to imagine this more of a being a "clue" or "suggestion" rather than a definitive answer of what's being returned.
For example, let's imagine that the code in the build
method gets updated so that the $data
field that's going to be returned is now an associative array (with strings as keys and integers for the values). If we forget to update the docblock, the method will still look the same on the surface.
Of course, if you are using a static analysis tool like PHPStan or Psalm, the chances of these types of problems can be reduced. However, if you have a particularly complex piece of code, it can sometimes be difficult to spot these issues. So, if you're using a static analysis tool, you can probably have more confidence in your docblocks showing the correct return information. But, it's important to remember that it's still not guaranteed.
2. Incorrect Variable Docblocks
This same type of thinking can be applied when looking at docblocks for the variables. For example, let's imagine that we have this simple example block of code using the session()
helper function (provided in Laravel) that let's us manage data in our user's session:
1$numbers = [1, 2, 3];2 3session()->put('numbers', $numbers);
As you can see, the code is really simple and adds an array of integers to the session with the key of numbers
.
Now if we wanted to retrieve that data from the session somewhere else in our codebase, we could use something like:
1$numbers = session()->get('numbers');
From a first glance, we can't actually see what data type the $numbers
field is. So, to give us more visibility of it's data type, we have three different options that we could take. The first would be adding a variable docblock above the line of code. The second would be adding moving the code into a separate method and adding a return type. The third would be using a data transfer object (DTO) to map our fields when we are storing them and then fetching them. Let's look at the approaches.
If we were to use the variable docblock, our code might now look like this:
1/** @var int[] */2$numbers = session()->get('numbers');
However, the issue with this approach is that if we update our code where we set the session data to store an array of strings, this docblock would be wrong. So, we would be working with data that isn't necessarily correct.
If we were to use the second approach and move the code into a separate method, it could look like this:
1function getNumbers(): array2{3 return session()->get('numbers');4}5 6$numbers = getNumbers();
As you can see, we'll now have the protection to ensure that whatever is returned from the session is always an array. However, we don't have any idea what data type the items are inside the array. Of course, we could add a docblock to the method, but then we end up with the same issue that we discussed above where it may become redundant and outdated. Another approach could be to add some validation into the method to ensure that the items are all integers. A very crude example could be:
1function getNumbers(): array 2{ 3 $numbers = session()->get('numbers'); 4 5 foreach ($numbers as $number) { 6 if (! is_int($number)) { 7 throw new \Exception('Not an integer'); 8 } 9 }10 11 return $numbers;12}13 14$numbers = getNumbers();
But the issue with doing this is that it's not that obvious at first glance what the items are in the array without reading the function body. When you are reading code in your projects, whether it be your own code or vendor code, it can save a lot of time being able to look at the docblocks and method signatures rather than trying to figure out what a method actually does. If we added the docblock, we would be able to solve the readability issue, but we would still have that same issue of having a potentially out of date docblock.
If we were to use the third option and use DTOs, we could explicitly define the data types that we are expecting. The spatie/data-transfer-object is usually very handy for use in Laravel projects. I personally really like this approach and have talked about using DTOs in my past blog articles. I actually discuss how to use DTOs in my Cleaning Up Laravel Controllers However, they're not always straight forward to implement in larger or more complex systems because it might mean that extra changes will need to be made. So, although, they're an ideal solution from a clean code perspective, they're not always feasible to implement in an existing project. But, saying that, if you have any easy chances to implement these in your own systems, it's something that I would probably encourage.
3. Helpful For Systems Without Tests Yet
During my time freelancing, I've come across a lot of different projects: some with adequately sized automated test suites; some with a handful of basic tests; and some with no tests at all.
Anyone who knows me will know that I actually enjoy writing tests. I love seeing them all pass and the satisfaction that it brings to other developers in the team when they can have more confidence making changes to the project. In fact, if you're interested in learning more about testing, check out my article about How to Make Your Laravel App More Testable.
I've often found that there's a rough correlation between the size of the test suite (in comparision to the size of the project) and the amount of bugs reported from the production environment. The larger projects with less tests tend to have more bugs than the projects with more tests.
So, the obvious thing to do is usually to add tests to the main pain points of the code where the bugs tend to occur. However, in larger and more complex systems, this isn't always straight forward. If a project hasn't been written with testing in mind, it's likely that the code is written in a way that makes it a bit trickier to write tests. For example, it might be more difficult to effectively mock dependencies for a method.
As a result of this, it can sometimes take longer to write tests than originally anticipated because you also need to rewrite some of the code to make it more testable. This is something that I've had to do on quite a few projects. In fact, on one project, I spent 5 days a week for 2 months doing nothing but writing tests for a project. And I still only managed to achieve around 25% code coverage (yes, it was a pretty big project!).
While adding tests for a lot of these projects and fixing bugs, I realised that quite a lot of the bugs could have been avoided with better type checking. Common examples of bugs were:
- A method that used to return an array of integers, but now returned an associative array where the keys were strings and the values were integers. Code elsewhere in the project wasn't updated to handle the fact that the array wasn't 0-indexed anymore and had strings for keys.
- A method that processed a Laravel
Collection
and made the assumption that all of the items in it wereUser
models. But, due to a bug in another method, theCollection
contained other types of models too.
So, with a stop-gap solution until more tests were written, a lot of these types of issues could have been prevented relatively simply.
The Type Safe Package
Okay, now that we've mentioned the reasons why I decided to make this package, let's take a look at what it actually does.
The package is really simple and basically acts as a way of checking the types of your variables at runtime.
At the point of me writing this, the package has 3 different types of checks:
- Simple checks
- Advanced checks
- Custom checks
Let's take a look at the different types of checks that we can use:
Simple Checks
Out of the box, we can validate that a variable is an integer, string, boolean, closure, object, array, or associative array.
For example, let's take our example code from above for retrieving data from the session in Laravel:
1$numbers = session()->get('numbers');
Now, if we wanted to add type safety to the $numbers
variable, we could update it to look like this:
1use AshAllenDesign\TypeSafe\Type;2use AshAllenDesign\TypeSafe\TypeSafe;3 4$numbers = TypeSafe::array(session()->get('numbers'));
Now from a quick glance at the code (especially once you're more used to the syntax), we can clearly see that we are fetching a data from the session with the key numbers
and ensuring that it's an array. If the data isn't an array, an \AshAllenDesign\TypeSafe\Exceptions\TypeSafeException
exception will be thrown.
Advanced Checks
As well as being able to check the type of a variable, we can also make some more assertions on the field itself.
To expand on our example from above, instead of just ensuring that our $numbers
field is an array, we can also check it's values data types like so:
1use AshAllenDesign\TypeSafe\Type;2 3$numbers = safe(session()->get('numbers'), Type::arrayOf(Type::INT));
Now, we've updated the check to ensure that data returned from the session is an array of integers. We can also use a similar approach if we had an associative array that we wanted to check:
1use AshAllenDesign\TypeSafe\Type;2 3$numbers = safe(4 session()->get('numbers'),5 Type::assocArrayOf(Type::STRING, Type::INT)6);
If we need to check that an object is of a particular class, we could write something like this:
1use App\Models\User;2use AshAllenDesign\TypeSafe\Type;3 4$numbers = safe($user, Type::object(User::class));
Custom Checks
You might want to use your own custom checks that aren't provided in the package by default. To do this, you can create your own class that implements the AshAllenDesign\TypeSafe\Check
interface.
The interface enforces two methods: passes()
and message()
. The passes()
method is used to define your logic that determines if the field is the correct type. The message()
method is used to return the message that will be passed to the thrown exception if the validation fails.
For example, if we wanted to create a custom check to assert that our field was a Laravel Collection
that only contained User
models, it might look something like this:
1use App\Models\User; 2use AshAllenDesign\TypeSafe\Check; 3use Illuminate\Support\Collection; 4 5class LaravelUserCollection implements Check 6{ 7 public function passes(mixed $prop): bool 8 { 9 if (!$prop instanceof Collection) {10 return false;11 }12 13 return $prop->whereInstanceOf(User::class)->count() === $prop->count();14 }15 16 public function message(mixed $prop): string17 {18 return 'One of the items is not a User model.';19 }20}
We could then use that check like so:
1$collection = collect([new User(), new TestCase()]);2 3safe($collection, new LaravelUserCollection());
The Benefits of Using the Package
Important Note
As I mentioned earlier, I made this package purely as a proof-of-concept to put together a few ideas that I'd had in my head for a while. Despite this, I do think that this package could serve some purpose and I can definitely see myself using this in the future.
However, it's worth noting that this package is by no means a replacement for automated testing, static analysis, documentation or docblocks. In my opinion, in an ideal world, code should be well tested, with good static analysis coverage and relevant documentation. However, as we all know, we don't live in an ideal world, and all of these things don't tend to happen; especially when working on legacy projects that you have no control over.
So, although this package can provide some type safety and checking, following best practices will usually (if not, always) yield better quality code.
Better "Code-as-Documentation"
On the other hand, the one thing that I do like about this approach is that it encourages you to treat your code as up-to-date documentation. As I mentioned, docblocks can sometimes become redundant and not accurately show the actual data type of the returned data. However, by using the Type Safe package, the code gives you a clear indicication of what is being returned. And, if the code is updated and returns a different data type, the package will catch this and throw an exception.
Using an example from earlier, let's say that we are adding data to the session with the numbers
key like so:
1$numbers = [1, 2, 3];2 3session()->put('numbers', $numbers);
As we've already seen, we can get the data from the session like this:
1use AshAllenDesign\TypeSafe\Type;2 3$numbers = safe(session()->get('numbers'), Type::arrayOf(Type::INT));
But, as an example, in the future, we may decide to change the data from an array of integers to an array of strings:
1$numbers = ['1', '2', '3'];2 3session()->put('numbers', $numbers);
If we were to continue reading the data from the session using our above previous method, it would throw an exception because it is expecting the data to be an array of integers. So, we could update our code like so:
1use AshAllenDesign\TypeSafe\Type;2 3$numbers = safe(session()->get('numbers'), Type::arrayOf(Type::STRING));
As a result of doing this, we've also managed to update our "code-as-documentation" so that we can clearly see what $numbers
is equal to.
Useful Debugging Tool
As well as this, this can also act as a really handy debugging tool for your methods when trying to track down a bug. For example, let's take the method that we quickly made earlier on to validate that all of the items being returned in an array were integers:
1/**2 * @return int[]3 */4function getNumbers(): array5{6 return session()->get('numbers');7}8 9$numbers = getNumbers();
If the numbers
field was actually holding an array of strings rather than numbers we'd need to update the docblock. We've already discussed further up how we could add better type safety to this method. But they required a bit more of hands-on approach.
However, let's imagine that you are working on a project and have been tasked with having to find a bug. You think that you might tracked it down to this particular method. So, then let's say that you want to assert that what the docblock is telling you is being returned is actually being returned. To do this, you could update the method to look like so:
1use AshAllenDesign\TypeSafe\Type; 2 3/** 4 * @return int[] 5 */ 6function getNumbers(): array 7{ 8 return safe(session()->get('numbers'), Type::arrayOf(Type::INT)); 9}10 11$numbers = getNumbers();
By making this change, you can be sure that what is being returned is in fact an array of integers. This means that when you run the code in your local development environment, you'd have a bit more confidence that the docblock is actually correct.
Now, I know what a lot of you are thinking when you're reading this: "There should be an automated test to spot this error!". And I 100% agree! But, as I've mentioned above, it's not always possible to write tests straight away for some classes and methods (especially when you've only just been brought onboard to work on an existing project). You also have the problem that sometimes methods can be so complex that you might not be able to cover all of the possible scenarios with your tests. So, although the code above doesn't really do any harm, in an ideal world, it would be reverted back to how it was originally after an adequate amount of tests are written.
Skipping the Checks in Production
As an added bonus, there may be times when you don't want to run the type checks. For example, you might want to disable them in production environments and only run them in local, testing and staging environments (for things like debugging and working on existing features). To skip the checks, you can simply use the skipChecks
like so:
1use AshAllenDesign\TypeSafe\Type;2use AshAllenDesign\TypeSafe\TypeSafe;3 4TypeSafe::skipChecks();5 6$validatedField = safe($field, Type::ASSOC_ARRAY);
Conclusion
Hopefully, this post should have given you a brief overview of what type safety is and how we can use the Type Safe package to achieve slightly better type safety in our projects.
If you're interested in checking out the repository on GitHub, you can view it here: ash-jc-allen/type-safe
If this post helped you out, I'd love to hear about it. Likewise, if you have any feedback to improve this post, I'd also love to hear that too.
If you're interested in getting updated each time I publish a new post, feel free to sign up for my newsletter below.
Keep on building awesome stuff! 🚀