The Grouparoo Blog
At Grouparoo, we use a lot of TypeScript. We are always striving to enhance our usage of strong TypeScript types to make better software, and to make it easier to develop Grouparoo. Strong types make it easy for team members to get quick validation about new code, and see hints and tips in their IDEs - a double win!
Recently, I found myself repeating a lot of metadata when defining a new API endpoint as I was working to enable noImplicitAny
within the @grouparoo/core
project. We use Actionhero to build Grouparoo, and so a typical Action might look like:
import { Action } from "actionhero";
export class TestAction extends Action {
constructor() {
super();
this.name = "testAction";
this.description = "I am a test";
this.inputs = {
key: {
required: true,
formatter: stringFormatter,
validator: stringValidator,
},
value: {
required: true,
formatter: integerFormatter,
},
};
}
// <--- Note the type definition below for `params`
async run({ params }: { params: { key: string; value: string } }) {
return { key: params.key, value: params.value };
}
}
function stringFormatter(s: unknown) {
return String(s);
}
function integerFormatter(s: unknown) {
return parseInt(String(s));
}
function stringValidator(s: string) {
if (s.length < 3) {
throw new Error("inputs should be at least 3 letters long");
}
}
Notice how the params provided back to the run()
method are typed, even though we provide that information functionally via the formatter
argument to the Action's inputs. Defining this information in both locations was tedious, and more nefariously, a possible place for drift between the implementation and the types. What would it take for TypeScript to automatically be able to determine the types of our Params?
Learning Things
I tried many approaches to programmatically determine the types of an Action's params, and learned a lot along the way. The most interesting thing that I learned was that method argument types are not inherited in TypeScript. Initially, I wanted to modify the abstract base Action
class to automatically reflect its input types into the run method, but it's not possible:
Consider the following:
abstract class Greeter {
abstract greet(who: string, message: string): void;
}
class ClassyGreeter extends Greeter {
greet(who, message) {
console.log(`Salutations, ${who}. ${message}`);
}
}
const classyGreeterInstance = new ClassyGreeter();
classyGreeterInstance.greet("Mr Bingley", "Is it not a fine day?"); // OK, inputs are strings
classyGreeterInstance.greet(1234, false); // Should throw... but it doesn't!
Even though ClassyGreeter
extends Greeter
, the fact that the greet()
method is re-implemented means that the initial type of the method from the abstract class can't be assumed. After hitting that dead end, I pivoted to attempt to build a transformation utility type. While working on this, I found myself inspecting the properties of the Action
class in question, and I learned was that TypeScript doesn't really know what goes on in a Class constructor.
For example, you can define the same class both ways:
class ConstructedList {
items: string[];
constructor() {
this.items = ["apple", "banana"];
}
}
class StaticList {
items: ["apple", "banana"];
}
typeof ConstructedList().items; // string[]
typeof StaticList().items; // ['apple', 'banana']
At runtime, these 2 classes will have the same behavior, with this.items = ["apple", "banana"]
, but because the class property was defined strictly in StaticList
, we can get the literal types back, rather than just the "string[]" we get from ConstructedList
.
The ParamsFrom
Type Utility
Knowing the above, it became clear that to reach the goal, I would need to reformat all of our action definitions to not use a constructor. After that, TypeScript can start to inspect the properties of the class. Our utility can take in the Action's class as an argument, and inspect both the keys of the inputs
, and if there is a formatter
present, infer its return type:
export type ParamsFrom<A extends Action> = {
[Input in keyof A["inputs"]]: A["inputs"][Input]["formatter"] extends (
...ags: any[]
) => any
? ReturnType<A["inputs"][Input]["formatter"]>
: string;
};
Of note, because we are accepting data over HTTP or websocket most commonly, we can assume that an input without a formatter is a string.
Putting everything together, here's what our final Action looks like:
import { Action, ParamsFrom } from "actionhero";
export class TestAction extends Action {
name = "testAction";
description = "I am a test";
inputs = {
key: {
required: true,
formatter: stringFormatter,
validator: stringValidator,
},
value: {
required: true,
formatter: integerFormatter,
},
};
async run({ params }: { params: ParamsFrom<TestAction>) {
return { key: params.key, value: params.value };
}
}
function stringFormatter(s: unknown) {
return String(s);
}
function integerFormatter(s: unknown) {
return parseInt(String(s));
}
function stringValidator(s: string) {
if (s.length < 3) {
throw new Error("inputs should be at least 3 letters long");
}
}
And finally, we can see our params are typed:
Open Source Contribution
We contributed this work back to Actionhero, and in Actionhero v28.1.0, the ParamsFrom
utility is included!
Tagged in Engineering
See all of Evan Tahler's posts.
Evan is the CTO and co-founder of Grouparoo, an open source data framework that easily connects your data to business tools. Evan is an open-source innovator, and frequent speaker at software development conferences focusing on Product Management, Node.JS, Rails, and databases.
Learn more about Evan @ https://www.evantahler.com
Get Started with Grouparoo
Start syncing your data with Grouparoo Cloud
Start Free TrialOr download and try our open source Community edition.