For generic functions, the caller specifies the type parameter, not the implementation. Often this specification happens automatically via type inference, but it is still technically in the control of the caller and not the implementer.
Let's look at this implementation of apply():
const apply = <T, U>(x: T, f?: Fn<T, U>): U => f ? f(x) : x;
If the caller calls apply() with two function parameters, everything is fine:
apply(Math.random(), (x: number) => String(x)).toUpperCase();
Here, the compiler infers number for T and string for U. It is equivalent to the caller writing this:
apply<number, string>(Math.random(), (x: number) => String(x)).toUpperCase(); // okay
If the caller tried to pass the same function parameters in while specifying incompatible type parameters, the compiler would warn:
apply<string, number>(Math.random(), (x: number) => String(x)).toUpperCase(); // error!
// -----------------> ~~~~~~~~~~~~~ ---------------------> ~~~~~~~~~~~
// number is not string number has no toUpperCase
So everything is as it should be.
If the caller calls apply() with just one function parameter, though, issues can occur:
apply(Math.random());
Here, the compiler infers number for T, but there is nothing from which to infer U. Thus inference fails and the compiler falls back to the unknown type. So it's equivalent to doing this:
apply<number, unknown>(Math.random()); // okay
That's still all right, since treating the output as unknown is safe... albeit useless. But watch what happens when the caller manually specifies other types for U:
apply<number, string>(Math.random()).toUpperCase(); // okay?!
// RUNTIME ERROR!!!
The caller has basically said "please return a string", which is allowable. The caller is in control of what T and U are. And so the compiler thinks that is what has occurred. Thus there is no compiler error when you try to call the output's toUpperCase() method. But at runtime this explodes.
Because the implementation is wrong. And the compiler was telling you that:
const apply = <T, U>(x: T, f?: Fn<T, U>): U => f ? f(x) : x;
// ------------
// Type 'T | U' is not assignable to type 'U'.
// 'U' could be instantiated with an arbitrary type which could be unrelated to 'T | U'.
// Type 'T' is not assignable to type 'U'.
// 'U' could be instantiated with an arbitrary type which could be unrelated to 'T'
It's saying that you are claiming to return a value of type U, but in actuality are returning a value of type T | U. If it turns out that U is some arbitrary type (like string in the example), and doesn't happen to coincide with T (like number in the example), then you are not fulfilling the contract, and bad things can happen (like runtime errors when people call your function).
So, what can be done? If you are not really concerned with the (admittedly unlikely) possibility that a caller will manually specify U with some random type and expect magic to happen, you can just use a type assertion to tell the compiler not to worry about the possible issue:
const applyAssert = <T, U = T>(x: T, f?: Fn<T, U>): U => f ? f(x) : x as any as U;
By x as any as U I'm saying "yes, I know that you can't be sure that x is a U, but I'm telling you that it will be." Furthermore I gave U a default of T (via U = T) so that when the compiler cannot infer U it will fall back to the default. This gives you the presumably intended behavior here:
applyAssert(Math.random()).toFixed(2); // okay
// const applyAssert: <number, number>(x: number, f?: Fn<number, number> | undefined) => number
But remember, it does not protect against evil/confused callers:
applyAssert<number, string>(Math.random()).toUpperCase(); // okay?!
If you want to make apply() completely type safe, it is more complicated, and you still have to use something like assertions to help the compiler along. Perhaps something like this:
function applyOverload<T>(x: T): T;
function applyOverload<T, U>(x: T, f: Fn<T, U>): U;
function applyOverload(x: any, f?: Function) {
return f ? f(x) : x;
}
This is an overloaded function with two distinct call signatures: one for a single argument, and one for two arguments. If you call it with one argument, there is no U, only T. So it becomes impossible to call it incorrectly, even manually:
applyOverload(Math.random(), (x: number) => String(x)).toUpperCase(); // okay
applyOverload(Math.random()).toFixed(); // okay
applyOverload<number, string>(Math.random()); // error! expect 2 arguments, but got 1
As for why this is "like assertions", overloaded functions are not generally safe. Implementations are checked loosely. Above, you can see that the implementation takes arguments of the weak types any and Function and returns a value of type any. It happens that we implemented the function properly, but the compiler wouldn't notice or care if we did something weird here:
function applyBadOverload<T>(x: T): T;
function applyBadOverload<T, U>(x: T, f: Fn<T, U>): U;
function applyBadOverload(x: any, f?: Function) {
return "oopsie"; // no error
}
So you need to be careful either way.
Playground link to code