UPDATE: Full code available at this gist (also embedded below).
Say you have the following
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
Keeping things type-safe looks really tedious! There’s obviously a
M. Can we avoid writing them by hand?
Can we derive
I? Can we derive
The answer to all of these questions is yes!1
TL;DR TypeScript lets us define generic types
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
selector, the name of the method the message intends to invoke,
args, the provided arguments to the method. The
extends never check is to help type inference deduce the empty
argument tuple: without it, the type system won’t complain about
I’ve also added a
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
Messages representing the methods
And that’s it! Here’s how it works:
MessagesProductis a mapped type that describes a modified interface, where all (and only) the method properties of interface
Iare rewritten to have a
Messageas their type, but keeping the same key;
...[keyof I]part in the definition of
Messagesuses index types to set up a union type built from all the value types (“indexed access operator”) associated with all the keys in
I(“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
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
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,
Conversely, given the
M from the top of the file, we get the
Roundtripping works too: both
Messages<Methods<M>> give what you expect.
TypeScript is really cool
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
EventMessages, 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 a
Messages<I>back into an interface,
.emacsTypeScript setup, based on the examples in the
(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)