What I would like to talk about is polymorphism, exactly ad-hoc polymorphism, and more exactly the wrong usage of ad-hoc polymorphism. Ad-hoc polymorphism in used when some function f
has different behavior for given argument a
being different type. To show what I mean, I will show example of monomorphic and polymorphic function:
[Pseudo Code TS flavor]
function monoF(a: number): number => { /* implement. */ }
// overloaded function with two implementations:
function poliF(a: string): string => { /* implement. */ }
function poliF(a: number): number => { /* implement. */ }
As you can see monoF
allows only number to be passed, and this function also returns one type - number
. The poliF
has two implementations, it is overloaded for string
and number
type.
The issues with ad-hoc polymorphism
What is then problem with such ad-hoc polymorphism? The problem is that it often leads to wrong design. In TypeScript function overloads is even more difficult as TS not allows on many implementations, implementation can be one and single, what force us into function with multiple branches.
[JS]
function isAdult(u){
if (typeof u === 'number') {
return u >= 18;
} else {
return u.age >= 18;
}
}
From deduction of the implementation we can understand that it works for two possible types, one is number
and second object
with age
property. To see it more clear let's add TypeScript types.
[TS]
function isAdult(u: number | {age: number}): boolean {
if (typeof u === 'number') {
return u >= 18;
} else {
return u.age >= 18;
}
}
isAdult(19)
isAdult(user)
Ok now we see more, our function in hindley milner notation has a type number | {age: number} -> boolean
.
Consider that our isAdult
function is able to cover two separated types and map them to boolean
. Because of these two types we were forced to append condition inside the implementation, as the function is rather simple this is still additional complexity. I can say isAdult
is a function merged from two number -> string
and {age: number} -> string
. And what is the purpose of this? Ah - flexibility, this function can be used in two different cases. But let's consider simpler version.
[TS]
function isAdult(u: number): boolean {
return u >= 18;
}
// usage
isAdult(19)
isAdult(user.age)
The only difference is the need to pass user.age
instead of user
. But such approach removed most of the code inside the function, also from beginning the only thing this function cared for was the age represented as number
.
Let's take a look at ad-hoc polimorhism which includes also return type.
[TS]
function add(a: string, b: string): number
function add(a: number, b: number): number
function add(a: string | number, b: string | number) {
if (typeof a === 'string' && typeof b === 'string') {
return parseInt(a) + parseInt(b)
}
if (typeof a === 'number' && typeof b === 'number'){
return a + b;
}
return a; // the dead code part
}
const a = add(1, 2)
const b = add("1", "2")
As it is visible code is quite terrible. We need to check variables types by runtime typeof
, also we introduced the dead code part, taking overloads into consideration there really is no other case then pair (number, number)
and (string, string)
, but our implementation sees all possible cases so also pairs (string, number)
and (number, string)
.
Ts is not able to have function overloads in form of own implementations as for example C++ has, because TS has no way to check what really type do we have, as type is only annotation not existing in the runtime
To be fair we can little bit change the implementation, but the only way is to use here type assertion.
function add(a: string | number, b: string | number) {
if (typeof a === 'string') {
return parseInt(a) + parseInt(b as string) // type assertion
}
return a + (b as number); // type assertion
}
Is it better, not sure. Type assertion are always risky, type safety loose here.
Lets now think why do we at all do that, why we need two input types? We abstract from developer the need of parsing a string to int. Is this game worth the candle? No it is not.
Some of you can say that union creates a new type, as we can say
type T = string | number
, yes that is true, and in that term functionT -> T
is not polymorphic
The smaller monomorphic version
function add(a: string, b: string) {
return parseInt(a) + parseInt(b)
}
And for numbers u have already +
operator. Nothing more is needed.
The real example of wrong design
Next example is from the real code and the question from stackoverflow - How to ensure TypeScript that string|string[] is string without using as?
We want to have a function which is overloaded in such way, that for string
returns string
and for array of strings
, return array of strings
. The real purpose of having this duality is - to give developers better experience, probably better ...
Its also very common in JS world to give ad-hoc polymorphism in every place in order to simplify the interface. This historical practice I consider as wrong.
function f(id: string[]): string[];
function f(id: string): string;
function f(id: string | string[]): string | string[] {
if (typeof id === 'string') {
return id + '_title';
}
return id.map(x => x + '_title');
}
const title = f('a'); // const title: string
const titles = f(['a', 'b', 'c']); // const titles: string[]
What we gain here, ah yes developer can put one element in form of plain string, or many inside an array. Because of that we have introduced complexity in the form of:
- conditions inside implementations
- three function type definitions
What we gain is:
- use string for one element :)
Ok, but what wrong will happen if the function will be refactored into monomorphic form:
function f(id: string[]): string[] {
return id.map(x => x + '_title');
}
const title = f(['a']); // brackets oh no :D
const titles = f(['a', 'b', 'c']);
The real difference is that we need to add brackets around our string, is it such big issue? Don't think so. We have predictable monomorphic function which is simple and clean in implementation.
What about Elm
Let's switch the language to Elm, Elm is language which is simple and follow very strict rules. How ad-hoc polymorphism is resolved here? And the answer is - there is no such thing. Elm allows for parametric polymorphism, which should be familiar for you in form of generic types in many languages, but there is no way to overload functions in Elm.
Additionally such unions like string | string[]
are not possible in Elm type system, the only way how we can be close to such is custom sum type. Consider following Elm example:
[ELM]
type UAge = Age Int | UAge { age: Int } -- custom type
isAdult : UAge -> Bool
isAdult str = case str of
Age age -> age >= 18
UAge u -> u.age >= 18
-- using
isAdult (UAge {age = 19})
isAdult (Age 19)
In order to achieve the same in Elm, we need to introduce custom type, the custom type is simulating number | {age: number}
from TypeScript. This custom type is a sum type, in other words we can consider that our function really is monomorphic as the type is defined as UAge -> Bool
. Such practice in Elm is just a burden, and it is a burden because its not preferable to follow such ideas. The whole implementation should look like:
[ELM]
isAdult : Int -> Bool
isAdult age = age >= 18
-- using
isAdult user.age
isAdult 19
And if you really have a need to call isAdult
for user record, then use function composition
[ELM]
isUserAdult: { age: Int } -> Bool
isUserAdult u = isAdult u.age
Function isUserAdult
is just calling isAdult
. The original function is user context free, it is more flexible to be used, is ideal component, and we can use isAdult
for other objects not only with age
property.
Is ad-hoc polymorphism always wrong
No, but we need to be careful with this tool. Such polymorphism gives a lot of flexibility, we can overload functions to work with different type of objects. The whole Haskell type system is based on parametric and ad-hoc polymorphism, the later is implemented there in form of typeclasses. Thanks to such you can for example use operators like <$>
or >>=
for different instances. It is very powerful tool, but also one of main reasons why Haskell code is so difficult to grasp, level of abstraction is often very high and this is also because when you look at functions or operators, they can have different implementation for different types.
More low level and very usable example of ad-hoc polymorphism is C++ function like to_string
function which has many overloads for many types. That kind of usage is very useful. Consider what a burden it would be if you would need to create a different name for your log
utility function for every different type.
Functions and operators overloads is also very handy tool for introducing own algebras, if you want more information about this topic consider the series of articles about algebraic structures.
Conclusion. Use function overloads carefully, don't put complexity were it is not needed, there is no issue in putting value into brackets, function flexibility is not always the good thing. Consider composition over multi-purposes functions.
PS. Sorry for clickbait title
Top comments (1)
The type assertion example can be made safer with this signature:
Anyway, I agree with you that
isUserAdult
is the simplest way to solve this. :-)