It's a classical mistake. You have defined a method that accepts some parameters and you forward them to another method.
public createUser(email: string, password: string) {
return this.userRepository.create(email, password);
}
Is the order of arguments really correct? Or did we by accident mix up the user and the password?
After causing a bug by mixing up the parameter order, we eventually use the typesystem to our advantage to prevent this from happening again.
Instead of using stringly typed parameters, we define proper types:
public createUser(email: Email, password: Password) {
return this.userRepository.create(email, password);
}
That's better. Now, if the order of parameters is different between our createUser
method and the repository we use, we will get a compile error.
At least if we used actually classes and not just type aliases - but this is more of typescript-specific problem not a general one.
This is cool, but when applying this style broadly, we quickly end up with quite verbose code, repeating names for both identifiers and types all over the place.
One might argue that this is okay, because we might want to use the same type multiple times. For instance, if the user where to also provide an email-address for recovery, the method might look like this:
public createUser(email: Email, password: Password, recoveryEmail: Email) {
return this.userRepository.create(email, password, recoveryEmail);
}
However, now we are back to our original problem and might mix up the regular email with the one for recovery purposes.
Consequently, we should make our types more precise again and have specific types for the email addresses, e.g.:
public createUser(email: DefaultEmail, password: Password, recoveryEmail: RecoveryEmail) {
return this.userRepository.create(email, password, recoveryEmail);
}
But now we also have the same kind of verbose, duplicated code again.
Kill all the identifiers #
Which brings up a question: if we meticulously follow the pattern of creating distinct types for specific entities, do we then still need identifiers at all?
That's an interesting thought-experiment. What if we just were to remove those parameter-identifiers from language specification alltogether?
Here is how that could look like:
public createUser(DefaultEmail, Password, RecoveryEmail) {
return this.userRepository.create(DefaultEmail, Password, RecoveryEmail);
}
The parameter names are gone and to call the repository, we simply pass them by referring to the type-name instead.
Looks quite clean if you ask me. We also have the same type-safety guarantees as before.
But what about the code that we usually write within a method. We need to manipulate data, create temporary variables and so on.
Here's an example with the original code:
public createUser(email: DefaultEmail, password: Password, recoveryEmail: RecoveryEmail) {
const normalizedEmail = email.toLowerCase();
return this.userRepository.create(normalizedEmail, password, recoveryEmail);
}
We certainly need variable names here to disambiguate between the price and the discount. Imagine this to be production-code.
Maybe you wonder about the meaning of the number for the discount. Is that a fixed value as in $2 or is it a percentage or something else?
Since we are using a typesystem we should take advantage and improve our code with meaningful types:
public calculateTax(price: Price, discount: Discount, mode: OrderMode): Tax { ... }
It's not quite obvious what Price
and Distount
are exactly, but we can easily check how they are defined.
The type definitions might be defined like that:
type Price = Money
type Discount = Money
type Money = number
type OrderMode = "takeout" | "eatin"
Now looking at the code, it appears that we don't really need the variable names anymore.
public calculateTax(Price, Discount, OrderMode): Tax { ... }
Obviously that's illegal syntax in typescript, but let's go along with it for now.
The next question is how the implementation would look like.
Somehow we need to refer to the parameters. Here's how it could work:
public calculateTax(Price, Discount, OrderMode): Tax {
const base = Price - Discount;
if (OrderMode === "takeout") {
return base * 0.1;
} else if (OrderMode === "eatin") {
return base * 0.2;
}
}
The parameter-values are accessed just by their typenames.
Conflicts #
This works in the example but what if we have a method where the types are just equal? For instance division:
public minus(a: number, b: number): number {
return a - b;
}
Actually, if we try really hard to remember math classes back in the day, maybe we remember that there are already terms we can use:
public minus(Minuend, Subtrahend): Difference {
return Minuend - Subtrahend;
}
But programming languages need to be practical. We don't want to define a new type for such a case everytime we encounter such a situation.
Even if it maybe be "correct" to choose a distinct name in those cases, it is often just not practical and will make our code bloated since we need to define the types in addition to the method.
So just like we can just create identifiers out of thin air, let's allow the same for types.
public minus(Minuend: number, Subtrahend: number): number {
return Minuend - Subtrahend;
}
Now, that looks like we are back to parameter names.
But don't forget: we changed typescript the language and removed parameter names.
Instead, we have just created two new types inline, as if we had defined type Minuend = number
before.
This means we can now mix predefined types and types that we need to create on the fly:
public calculateTax(Price, Discount, OrderMode: "takeout" | "eatin"): Tax { ... }
Price
and Discount
have been defined in advance, but maybe OrderMode
wasn't. So we create it inline.
The call-side #
Until now, there isn't really a big advance.
The method signature gets a little bit shorter and it nudges us to use distinct, semantic types instead of generic types but that's about it.
The real difference happens at the call-side.
First of all, since our types are unique, the ordering stops mattering.
We can freely reorder our parameters both at call-side and at definition side.
const price: Price = ...
const discount: Discount = ...
calculateTax(price, discount, "takeout") // that's fine
// Just as good
calculateTax(discount, price, "takeout")
calculateTax("takeout", price, discount)
If you feel that it is inconsequent to remove parameter names but still use variables then I can only say that you have a very good gut feeling indeed.
I'll cover this later on, but let's keep on with this style for a bit.
Of course, if we are in the context of another method, maybe price and discount are already parameters!
public calculateFinalPrice(Price, Discount, CustomerPreferences): Money {
return (Price - Discount) + calculateTax(Price, Discount, CustomerPreferences.orderMode);
Wait a second - how would the compiler know, that the result can be the Money
type?
And also, if we have a method defined with semantic types that are both numbers, how to turn numbers into those specific types at call-site?
Type refinement and coercion #
There are two parts here. Let's look at the call-site first.
If want to call our method with two hardcoded numbers in code, how would that look like?
calculateTax(40, 15, "takeout")
This obviously won't do it.
In the current typescript world we could do something like that, but not in our new types-only world.
We have turn the values of type number into the correct types by converting them explicitly.
`calculateTax(40 as Price, 15 as Discount, "takeout" as OrderMode)`
We are reusing typescript's as
keyword here. The compiler checks if Price
is a specialized type of number
and converts it.
This also gets us back to being able to reorder parameters as we wish without changing semantics.
It should be noted that this is only necessary when going from a general type like number
to a more specialized like Price
.
Going from Price
to Money
or from one of those to number
would be automatic. Subtyping rules apply.
This might seem annoying at first, but such conversions become quite rare in most code if types are well utilized and reused.
It is a good incentive to keep the defined types tidy and organized and is incredible for self-documenting code (even though it can't replace all documentation obviously).
What about variables and return types? #
So far we have made a thought-experiment mainly about changing method signatures. Why not extend this to variables as well? And also return types. I don't see why not.
Going back to a previous example, which we have to refine now:
const price: Price = ...
const discount: Discount = ...
calculateTax(price, discount, "takeout" as OrderMode)
We can also reduce this to:
const Price = 40
const Discount = 15
calculateTax(price, discount, "takeout" as OrderMode)
And we could reduce it even further:
const 40 as Price
const 15 as Discount
calculateTax(price, discount, "takeout" as OrderMode)
Class fields and names and method names #
Why stop here?
Instead of defining a user as
class User {
public firstName: string;
public lastName: string;
public constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
we write it like that in our new world:
class User {
public firstName: FirstName;
public lastName: LastName;
public constructor(firstName: string, lastName: string) {
this.firstName = firstName as FirstName;
this.lastName = lastName as LastName;
}
}
As you can probably see, this looks quite strange. And that is good! It is an indication of bad design.
Instead of doing the transformation ourselves, we should push the types out into the world!
In a class definition, the compiler would do that for us:
class User {
// We just created two new public and refined types
public FirstName: string;
public LastName: string;
public constructor(FirstName, LastName) {
this.FirstName = FirstName;
this.LastName = LastName;
}
}
Now we create users and methods with those types:
const User("doe" as FirstName, "john" as LastName) as John;
public switchNames(User): User {
return new User(User.FirstName as LastName, User.LastName as FirstName);
}
const switchName(john) as CorrectedJohn