Thursday, August 26, 2021

TypeScript puzzle No.1 (intermediate)

There's a callable type definition
type Callable = {
    description: string;
    (a: string): string;
}
It's pretty obvious that if given an instance of the type, one can just call it:
function CallableClient( c: Callable, s: string ): string {
    return c(s);
}
The question is however, how to create instance of the type so that it's both callable and has the string property and the creation is strongly typed (doesn't involve any type). Formally, your task is to implement the factory method that takes a function, a description and composes them:
function F2Factory( f: (a:string) => string, description: string ): Callable {
    return ...; // ??
}

This is actually pretty interesting so here's goes the story. There are two possible approaches I am aware of.

First approach involves a const that is further expanded with an attribute:

const c : Callable = (s: string) => s;
c.description = "foo";

// c is correctly of Callable type and can be called
Note that it only work when the variable is declared as const, doesn't work with var/let. This should be enough to provide the implementation of the F2Factory

Second approach involves Object.assign which is typed as Object.assign<T, U>( t: T, u: U ) : T & U. It's great as it looks like it could just combine two objects and return a new object that act as both. Of course then, this works

function F2Factory( f: (a:string) => string, description: string ): Callable {
  return Object.assign(f, { description });
}

const c = F2Factory( (s: string) => s, 'foo' );

console.log( c('bar') );
console.log( c.description );
Note, however, that there's a caveat here. The Object.assign basically duplicates attributes of the source to the target. However, a callable object (a function) doesn't have any properties that can be duplicated to another object so that the other object would become callable too. This doesn't work then:
function F2Factory( f: (a:string) => string, description: string ): Callable {
  return Object.assign({ description }, f);
}

const c = F2Factory( (s: string) => s, 'foo' );

console.log( c('bar') );
console.log( c.description );
This could be surprising for someone, it doesn't work even though it types correctly!

The reason here is that for the typescript compiler, using Object.assign to combine a function and an object in any order produces the same output type. However, for the Javascript engine that actually runs the code, adding description to a function works but trying to duplicate callability to a simple object { description } by no means makes this object callable.

This discrepancy between the Typescript compiler and the Javascript runtime would be even more clear if we consider a type that combines two callable members

type Callable2 = {
    (a: number): number;
    (a: string): string;
}
This time the Object.assign will never yield expected output - it can't combine two functions to create yet another function that correctly handles input argument type.