Today I had to write some TypeScript code again, in particular the pipe
function. It takes any amount of functions and composes them left to right. In JavaScript this function is fairly easy to implement:
function pipe(...fns) {
return argument => {
let result = argument;
for (let i = 0; i < fns.length; i++) {
result = fns[i](result);
}
return result;
};
}
As you can see, we just repeatedly apply the argument to the functions one by one and return the final result. The problem is, we can't really provide a good type for this in TypeScript:
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
return (argument: any) => {
let result: any = argument;
for (let i = 0; i < fns.length; i++) {
result = fns[i](result);
}
return result;
};
}
For me, the types in the function itself are fine. The function is pretty simple, so I don't care if result
has type any
or not. But the types the function exposes for others are not acceptable. It just tells us that the function expects many single argument functions and returns one single argument function. I want to use TypeScript to ensure that all the functions I pass in are compatible and fit together. I also want that the returned function has the input type of the first function and the return type of the last.
Sadly the type system of TypeScript is not strong enough to express this function, this would need some sort of type level fold operation while TypeScript only has mapped types.
Function overloading
Since the beginning of TypeScript, the answer to such problems has been function overloading. As long as the function type is more general, you can add any amount of additional, more concrete type signatures to provide better types. For example, if you have a function that can work with string
and number
:
// These are the overloads
function doSomething(input: string): string;
function doSomething(input: number): number;
function doSomething(input: string | number): string | number {
return input;
}
As you can see, the base type is pretty general, because even if you pass in a string
, the type would still allow to return a number
. But this is not what the implementation does! It always returns the same type as the input. So we can add two overloads to fully cover all the possible input types and specify their return types. Note how the types in the overload are still possible in the actual, general type. This is needed in TypeScript, because it can't to type directed overloading like Java or C++, so you can just constrain the general type with overloads. This for example, would be a type error because the general type does not allow objects.
// These are the overloads
function doSomething(input: string): string;
function doSomething(input: number): number;
function doSomething(input: {}): {}; // Error
function doSomething(input: string | number): string | number {
return input;
}
Back to pipe
So we can fix our bad pipe
type with overloads. We can not provide all possible overloads because pipe can take any amount of arguments and we can only provide a finite amount of overloads. But in reality you would not expect people to use more than let's say 20 arguments at once. And even if they do, the function will still work, because TypeScript will fall back to the general type.
So let's start with the simplest overload: For just one function.
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
With only one function, pipe is the identity, it behaves like the function passed in. Now we extend the overload to two functions:
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe<A, B, C>(fn1: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
I think the pattern should be pretty obvious. We just add another parameter that fits to the one before and change the overall return type. Sadly, this is really tedious to do by hand, especially if we want to have overloads for up to 20 arguments!
Vim macros to the rescue
The pattern to create new overloads is pretty regular, we should somehow be able to automate this. Luckily my favorite text editor comes with the tools needed for this: vim macros.
A vim macro is just the editor recording every keystroke that you make. This includes any vim commands in normal mode and anything you write in insert mode. To record a macro you have to press q
followed by one other letter. This letter will be the name of the macro, so you can have multiple macros in parallel. As we want to do overloading, let's use o
. Once you now have pressed qo
, you should see recording @o
in the bar at the bottom. This means that vim is now listening to your keystrokes.
Now press i
to go into insert mode, write some short text and finished with a press on escape to leave insert mode again. Press q
to stop recording. To play back a macro you can hit @o
(where o
is of course the letter you used while recording) and you will see the same text you have just written appear again.
The last bit of preparation that is needed is changing one setting about auto-increment (we will use this later). When in normal mode (just hit escape to be sure), type :set nrformats=alpha
and hit enter. This will allow us to not only increment numbers, but also letters.
Recording our macro
We start again with the function and those two overloads.
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe<A, B, C>(fn1: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
Now, put the cursor on the line with the second overload and hit qo
to start recording. Follow with a press on 0
to jump to the start of the line. Then we want to create a new overload, so we copy and paste the current line. We can do this with yy
(yank) and p
(paste).
So what is our goal now with our fresh overload? First, we want to add a new generics name at the end of all the other ones. For this, we jump to the >
with f>
. After that, we need to copy the last generic name (C
in our case). Use yh
to copy the character on the left. Now we need to add the comma and the space. For this we can simply go into insert mode with a
and type out ,
. Leave insert mode again with escape. Paste the character with p
. You should have this now:
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe<A, B, C>(fn1: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
function pipe<A, B, C, C>(fn3: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
// ^ Cursor should be here
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
Now comes the magic trick: Press Ctrl+A to increment the letter. This is why we needed to change that setting earlier. This will turn the C
into a D
, but it will also do that for any other letter. This is important because we want to reuse our macro to create many lines automatically where the letter would be different each time.
The next step is adding a new argument. For this, we first jump to the end of the line with $
. Then we jump to the comma in front of the last argument with F,
. To copy the last argument, we need to press y2t)
which means "yank to second )" aka copy everything until the second closing parenthesis (the first one is part of the type). Now we jump forward to the end of the arguments with 2f)
(skipping the one parenthesis of the type). Pasting requires now a capital P because we want to paste before our cursor. The result should look like this:
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe<A, B, C>(fn1: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
function pipe<A, B, C, D>(fn3: (arg: A) => B, fn2: (arg: B) => C, fn2: (arg: B) => C): (arg: A) => C;
// ^ Cursor should be here
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
To finish the work on that argument, we need to change its name and adjust the types. To change the name we jump back two colons with 2F:
and go one further by hitting h
. The cursor is now over the 2
. With Ctrl+A we can again increment that number to 3
. To adjust the types we first go to the closing parenthesis with f)
and one character back with h
. Increment it with Ctrl+A. Now we jump to the second closing parenthesis with 2f)
and again go one back with h
and increment it with Ctrl+A. The final result looks like this:
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe<A, B, C>(fn1: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
function pipe<A, B, C, D>(fn3: (arg: A) => B, fn2: (arg: B) => C, fn3: (arg: C) => D): (arg: A) => C;
// ^ Cursor should be here
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
The last thing that is still missing is the return type of the function, but this is now rather easy. Jump to the end of the line with $
, go one back with h
and increment it with Ctrl+A. And we are done recording! Hit q
to stop it.
Reaping the benefits
That was quite a lot of work for just one single line, but when recording the macro, we never used any absolute positioning, we always jumped to landmarks like a parenthesis, a comma or the start and end of the line. This makes the command work even if there are more than just two arguments already defined. With the cursor still on the new overload press @o
and you will see a new overload appears right below the one that took us so much time.
function pipe<A, B>(fn1: (arg: A) => B): (arg: A) => B;
function pipe<A, B, C>(fn1: (arg: A) => B, fn2: (arg: B) => C): (arg: A) => C;
function pipe<A, B, C, D>(fn1: (arg: A) => B, fn2: (arg: B) => C, fn3: (arg: C) => D): (arg: A) => D;
function pipe<A, B, C, D, E>(fn1: (arg: A) => B, fn2: (arg: B) => C, fn3: (arg: C) => D, fn4: (arg: D) => E): (arg: A) => E;
function pipe(...fns: [(arg: any) => any]): (arg: any) => any {
/* body omitted */
}
Now to finish our 20 overloads we could manually do @o
a bunch of times, but you can also just put the cursor on the last overload and hit 16@o
. I chose 16 because we said 20 overloads were enough.
The full macro
Before recording the macro you need to type :set nrformats=alpha<enter>
in normal mode and the cursor needs be on the second overload.
qo // Start recording to register o
0 // Jump to the beginning of the line
f> // Jump to >
yh // Copy character to the left
a // Go into insert mode after the cursor
,<space> // Normal typing
<escape> // leave insert mode
p // Paste
<ctrl>a // Increment character
$ // Jump to the end of the line
F, // Jump back to the last comma
y2t) // Copy everything until the second closing parenthesis
2f) // Jump two closing parenthesis further
P // Paste before cursor
2F: // Jump back two colons
h // Go one character left
<ctrl>a // Increment number
f) // Jump to next closing parenthesis
h // Go one character left
<ctrl>a // Increment character
2f) // Jump two closing parenthesis further
h // Go one character left
<ctrl>a // Increment character
$ // Jump to the end of the line
h // Go one character left
<ctrl>a // Increment character
q // Stop recording
After recording press 17@o
to run the macro 17 times.
Conclusion
Vim commands and movements are very powerful. Even if you don't use them that often in your daily work or when you have just started using vim, after some time they will be a powerful ally to help automate repetitive tasks. Macros are one of the reasons why vim is my favorite editor and I think this example shows that while you (or at least I) don't need them on a daily basis, in some situations they are live savers.
Top comments (0)