Daniel Earwicker Chief Software Architect
FISCAL Technologies Ltd

TypeScript multicast functions


TYPESCRIPT 2016-03-13

Just as in JavaScript, C# functions are first class entities - you can pass them around in variables. There are two ways that C# differs from JavaScript.

  1. a method's this reference is automatically bound to the object it belongs to. In JS a "method" is just an object property that happens to contain a function. If copied into a separate variable and then called, there may or may not be a problem depending on whether the function internally refers to this.

  2. a function value (known as a "delegate") has operators +, -, +=, -= that allow it to be combined with other compatible functions to create a new single function that, when invoked, causes the constituent functions to be invoked.

The second one is what I'm interested in today, mainly because it's a nice example of something that we can strongly type check in TypeScript. Internally (at the "plumbing" level) we have to bypass type checks, but externally we can guarantee everything will work.

Browse the source code

A multicast<TFunc> is a function whose type is the intersection of TFunc and some methods called add and remove. To declare it, we have to say:

interface Subscribable<TFunc extends Function> {
    add(handler: TFunc): Multicast<TFunc>;
    remove(handler: TFunc): Multicast<TFunc>;
}

type Multicast<TFunc extends Function> = Subscribable<TFunc> & TFunc;

This is a neat trick, but it seems odd that we can't say:

interface Multicast<TFunc extends Function> extends TFunc {
    add(handler: TFunc): Multicast<TFunc>;
    remove(handler: TFunc): Multicast<TFunc>;
}

TypeScript currently won't let an interface extend one of its type parameters. But the first snippet achieves the same thing by roundabout means, so it seems to be just a curious historical limitation and maybe it will be lifted in a future version of the compiler (it's evolving so fast).

In short, the & operator ("intersection of types") allows two types to be merged: it outputs a type that has the capabilities of both input types. This is a vague way of saying it, and it makes "intersection" seem like the wrong name: an intersection is the (usually smaller) subset of items common to two (usually larger) sets. It's the little bit that overlaps. And yet here we're making a type that is "bigger" than the two inputs. To be more precise we need to do exactly the same mental switcheroo I described in TypeScript is not really a superset of JavaScript and that is a Good Thing: a type can be thought of as the set of values that are of that type. Thinking of it that way, only a subset of the values of type A will also be values of type B. So we are indeed talking about an intersection: of two sets of values.

The end product is a function that, as well as being directly callable, also has a couple of methods, add and remove, tacked onto it. Note that they each return a Multicast of exactly the same type; this is because (like C# delegates) I've made them immutable: you can't change what's on the list of an existing Multicast, but you can get a new Multicast with whatever alteration you require.

The implementation works by defining an object that implements Subscribable, and then "merging" it onto a forwarding function.

export default function multicast<TFunc extends Function>(...handlers: TFunc[]): Multicast<TFunc> {

    handlers = handlers;

    const subscribable: Subscribable<TFunc> = {
        add(handler) {
            return multicast(...handlers.concat(handler));
        },
        remove(handler) {
            return multicast(...handlers.filter(h => h !== handler));
        }
    };

    const invoke: TFunc = ((...args: any[]) => {
        let result: any;
        handlers.forEach(handler => result = handler.apply(null, args));
        return result;
    }) as any;

    return merge(invoke, subscribable);
}

Note the two different uses of the spread operator. First for handlers (you can pass any number of compatible functions as arguments to multicast to get them all joined together), and this lets us do immutable array manipulation (concat, filter) to create further calls to multicast when implementing add and remove.

Second, the ugly part: invoke. This is the basis of the function object we will return. It is basically completely untypechecked (note the as any!) But at the same time, it implements TFunc by calling on to handlers that implement TFunc, so it is type safe.

Note how discards all but the last return value. It would be nice if we could specify a custom reducer function:

(a: TReturn, b: TReturn) => TReturn

That would allow the user to request that return values should be summed, etc.

But TypeScript doesn't give us a way to find out the return type of a function type. It would be really powerful if we could discover "traits" about a type (a C++ idea) like that. Another example would be getting a tuple type of the parameters to a function type. And what if we could construct a new function type out of such pieces? Then we could do things like wrapping the return type in a promise. This would be useful for solving problems like making a promise-enabled version of any node.js style callback API. But I digress.

One final piece of the implementation is the merge call right at the end. This is the classic:

export default function merge<T1, T2>(onto: T1, from: T2): T1 & T2 {
    if (typeof from !== "object" || from instanceof Array) {
        throw new Error("merge: 'from' must be an ordinary object");
    }
    Object.keys(from).forEach(key => (onto as any)[key] = (from as any)[key]);
    return onto as T1 & T2;
}

As you can see, even this is not without its subtleties. It is a mutating operation (ugh) but that's because it's a low-level building block. It copies the properties of its second parameter onto its first. As long as the second parameter is a simple object then this is sufficient to make the first parameter gain the type of the second, thus honouring our claim to be returning T1 & T2. But there isn't (as far as I know) a way to specify that T2 must be a simple object in that sense, hence the runtime check. The user might pass two functions, for example. That's no good, as merging two functions can be a tricky problem even when you know what parameters they accept; if they could be any old functions then you are out of luck. JavaScript functions don't have "typed" parameters, so how can you decide which of the two input functions to dispatch a call to? Given this, I wonder why it is even possible for A & B to get past the compiler if both A and B are functions.

Time reversible events 2023-04-07
Language Smackdown: Java vs. C# 2023-03-07
Domesday '86 Reloaded (Reloaded) 2021-02-07
The Blob Lottery 2020-09-27
Abstraction is a Thing 2020-03-07
Unfortunate Bifurcations 2019-11-24
Two Cheers for SQL 2019-08-26
Factory Injection in C# 2019-07-02
Hangfire - A Tale of Several Queues 2019-05-24
How Does Auth work? 2018-11-24
From Ember to React, Part 2: Baby, Bathwater, Routing, etc. 2018-03-18
From Ember to React, Part 1: Why Not Ember? 2017-11-07
json-mobx - Like React, but for Data (Part 2) 2017-02-15
Redux in Pieces 2017-01-28
Box 'em! - Property references for TypeScript 2017-01-11
TypeScript - What's up with this? 2017-01-01
MobX - Like React, but for Data 2016-12-28
Eventless - XAML Flavoured 2016-12-24
Immuto - Epilogue 2016-12-20
Immuto - Radical Unification 2016-09-22
Immuto - Working with React (An Example) 2016-09-16
Immuto - Strongly Typed Redux Composition 2016-09-11
TypeScript - What is a class? 2016-09-11
TypeScript and runtime typing - EPISODE II 2016-09-10
TypeScript and runtime typing 2016-09-04
What's good about Redux 2016-07-24
TypeScript multicast functions 2016-03-13
Introducing doop 2016-03-08
TypeScript is not really a superset of JavaScript and that is a Good Thing 2015-07-11
A new kind of managed lvalue pointer 2014-04-27
Using pointer syntax as a shorthand for IEnumerable 2014-04-26
Adding crazily powerful operator overloading to C# 6 2014-04-23
Introducing Carota 2013-11-04
Want to comment on anything? Create an issue!