Making Code Reusable
One thing that people don't seem to naturally understand is that to make code reusable you have to make it generic. To get a better understanding of this lets walk through some examples and see if we can get a better understanding of some good ways to make code more generic.
Let's say we have a class called User
and user has a firstName
and
lastName
properties. It might look something like the following.
class User {
constructor(public firstName: string, public lastName: string) { }
}
Now we find out that there is a need to access the user's fullName
within the
application.
Computed Property/Getter
Most people's natural instinct is to simply add a fullName
getter to the
User
class as a computed property.
class User {
constructor(public firstName: string, public lastName: string) { }
get fullName(): string {
return this.firstName + " " + this.lastName;
}
}
This is great! Our immediate need is met. It lets us get the full name of a user as follows.
const user = new User('Bob', 'Villa');
const fullUserName = user.fullName;
However, this code isn't reusable. You need to ask yourself, "What does the action of combining a first name and last name conceptually have to do with a user?" The answer is, nothing other than that the user happens to have a first name and a last name.
So how do we go about making this code more reusable? Well, step one is going
back to the version of User
that didn't have a fullName
getter.
class User {
constructor(public firstName: string, public lastName: string) { }
}
Parameterized Function
Let's try pulling this fullName
computed property out into a parameterized
function and see how that feels.
function fullName(firstName: string, lastName: string): string {
return firstName + " " + lastName;
}
Now we can get the full name as follows.
const user = new User('Alice', 'Jones');
const fullUserName = fullName(user.firstName, user.lastName);
Ok now that we have extracted this into a function that is parameterized based
on the firstName
and lastName
arguments it is able to combine any pair of
firstName
and lastName
irrespective of where they came from. This is now
reusable by definition.
However, you may have noticed that the ergonomics is not quite as nice as it was with the Getter/Method approach. Let's see if we can improve the ergonomics a bit.
Function Parameterized to Anonymous Interface
One of the complexities we have is that with it strictly parameterized to
firstName
and lastName
we have to create a mapping between the
otherUser.firstName
and the otherUser.lastName
by passing them as
parameters. It would be nice if we could simply pass a User
to the fullName
function.
function fullName(user: User): string {
return user.firstName + " " + user.lastName;
}
This simply moves the knowledge of how to access the firstName
and lastName
of the User
object from outside the function to the inside the
function. This has a problem though. We just made the code no longer reusable,
because it once again only works with objects of type User
.
To address these we can use TypeScript's anonymous interface typing to specify
that the function should accept any object that has both a property of
firstName
and a property of lastName
. This looks as follows.
function fullName(named: { firstName: string, lastName: string }): string {
return named.firstName + " " + named.lastName;
}
Which allows us to now get the full name of the user as follows.
const user = new User('Bob', 'Villa');
const fullUserName = fullName(user);
Woot woot! We regained the generic and reusable nature that we had in the
parameterized function but in the cases where we have an object that matches
the properties of firstName
and lastName
it streamlines the usage. In the
cases where the properties don't match that we simply have to do a mapping
similar to the parameterized function example.
Another cool thing about this is that it is a function that takes a single argument which now plays much nicer in a functional programming world.
One question people generally ask at this point is where do I put this
fullName
function though. If it isn't part of User
it shouldn't be in the
same file as User
. I would argue you are correct. It has nothing to do with
User
. This is where the concept of domain, a.k.a. problem space, a.k.a.
concern comes into play. Organizing code based on concern is a much more useful
strategy than by data as data is a byproduct of the concern. In this particular
case we have a concern of "Name Composition" so I would move it to a
TypeScript module named name_composition.ts
for the time being.
You may have also noticed that we aren't 100% back to the ergonomics oh what we
had at the very beginning with the Getter/Method approach. We can gain the full
ergonomics back by adding the following getter to the User
class as follows.
class User {
constructor(public firstName: string, public lastName: string) { }
get fullName(): string {
return fullName(this);
}
}
This gives us the best of both worlds as it gives us the reusability of the
fullName()
function while also giving us the convenience of having the
operation be discoverable via tooling on the User
class. In the end giving us
the ability to get the fullName
with either of the two following approaches.
const user = new User('Bob', 'Villa');
const fullUserName = fullName(user);
const fullUserName2 = user.fullName;
I personally prefer not having the getter on the User
class as it is just
another layer of abstraction that isn't necessary. However, a lot of people
value the discoverability of operations through tooling on the User
class to
a high degree and think it is worth the cost.
Function Parameterized to Named Interface
Another option we have is to parameterize the function based on a named
interface. For example, we could have an interfaced called Named
which would
described our constraints of an object that has both a firstName
and
lastName
property.
interface Named {
firstName: string;
lastName: string;
}
This would then allow us to write our function as follows.
function fullName(named: Named): string {
return named.firstName + " " + named.lastName;
}
This is definitely more readable and easier to conceptually understand that the
fullName()
function operates on conceptually any named object. However, it
does add some more weight to the implementation. So I can see going back and
forth on this. I think if there is more than one function that operates on the
concept of a Named
object then it should definitely be formalized as a named
interface. If it is a weird one off concept and function maybe it is fine to
use the anonymous interface approach.
Conclusion
If we look back at what we did at a high level it looks as follows.
- question the belonging of some code
- identify that it is not owned naturally by the class
- extract it to a parameterized function
- decide to parameterize with an interface (either anonymous/named) or leave it multiple parameters
This process is always the same. You just rinse and repeat the above steps over and over again as you are coding, and you will end up with a far more robust, meaningful, and reusable code base.