TypeScript: Messages from Interfaces and back
Tue 16 Feb 2021 20:27 CET
UPDATE: Full code available at this gist (also embedded below).
Say you have the following
TypeScript interface I
that you
want to invoke remotely by passing messages of type M
; or that you
receive messages of type M
and want to handle them using an object
of type I
:
Keeping things type-safe looks really tedious! There’s obviously a
connection between I
and M
. Can we avoid writing them by hand?
Can we derive M
from I
? Can we derive I
from M
?
The answer to all of these questions is yes!1
TL;DR TypeScript lets us define generic types Messages
and
Methods
such that M = Messages<I>
and I = Methods<M>
. Read on
for the details.
Interface ⟶ Messages
Let’s start with what, for me, has been the common case: given an interface type, automatically produce the type of messages that can be sent to implementors of the interface.
First, how do we want to represent messages?
I’ve taken a leaf out of Smalltalk’s book, and made a message include
a selector
, the name of the method the message intends to invoke,
and some args
, the provided arguments to the method. The Args
extends never[]
check is to help type inference deduce the empty
argument tuple: without it, the type system won’t complain about
missing arguments.
I’ve also added a callback
to Message
. The technique I describe
here can be further extended to “asynchronous” or callbackless
settings with minor modifications.
The next definition, of type Messages<I>
, is where the magic
happens. It expands to a union of Message
s representing the methods
defined in I
:
And that’s it! Here’s how it works:
-
MessagesProduct
is a mapped type that describes a modified interface, where all (and only) the method properties of interfaceI
are rewritten to have aMessage
as their type, but keeping the same key; -
then, the
...[keyof I]
part in the definition ofMessages
uses index types to set up a union type built from all the value types (“indexed access operator”) associated with all the keys inI
(“index type query operator”).
Messages ⟶ Interface
Going in the other direction is simpler:
It’s a mapped type, again, that maps union members that have
Message
type to an appropriate function signature. It takes
advantage of TypeScript’s automatic distributivity: a union of
products gets rewritten to be a product of unions. Then, in the
conditional type M extends Message<...> ? ...
, it projects out
just exactly the member of interest again.2
This time we use the mapped type as-is instead of re-projecting it
into a union using indexed access like we did with MessagesProduct
above.
Type-safe interpretation of messages
Now we have types for our interfaces, and types for the messages that
match them, can we write a type-safe generic perform
function? Yes,
we can!
An example
Given the above definition for I
, actually using Messages<I>
produces the following type definition3 (according
to the IDE that I use):
Conversely, given the M
from the top of the file, we get the
following for Methods<M>
:
Roundtripping works too: both Methods<Messages<I>>
and
Messages<Methods<M>>
give what you expect.
TypeScript is really cool
It’s a fully-fledged, ergonomic realization of the research started by Sam Tobin-Hochstadt, who invented Occurrence Typing, the technology at the heart of TypeScript.
Then, building on the language itself, emacs with tide, flycheck, and company makes for a very pleasant IDE.4
Congratulations to Sam, whose ideas really have worked out amazingly well, and to the TypeScript team for producing such a polished and pleasant language.
Appendix: Full code implementing this idea
This module implements the idea described in this article, extended
with the notion of EventMessage
s, which don’t have a callback.
-
Well, at least in TypeScript v4.x, anyway. I don’t know about earlier versions. ↩
-
Actually I’ll admit to not being quite sure that this is what’s really going on here. TypeScript’s unions feel a bit murky: there’s been more than one occasion I’ve been surprised at what a union-of-products has been automatically “simplified” (?) into. ↩
-
Hey, what’s going on with those named tuple slots? I would have expected a tuple type like
[number, string]
not to be able to have names attached to the slots, but it turns out I’m wrong and the compiler at least internally propagates names in some circumstances! It even re-uses them if you convert aMessages<I>
back into an interface,Methods<Messages<I>>
… ↩ -
Here’s my
.emacs
TypeScript setup, based on the examples in thetide
manual:(defun setup-tide-mode () (interactive) (tide-setup) (flycheck-mode +1) (setq flycheck-check-syntax-automatically '(save mode-enabled)) (eldoc-mode +1) (tide-hl-identifier-mode +1) (company-mode +1) (local-set-key (kbd "TAB") #'company-indent-or-complete-common) (local-set-key (kbd "C-<return>") #'tide-fix)) (setq company-tooltip-align-annotations t) (add-hook 'typescript-mode-hook #'setup-tide-mode)