actionhero engineering grouparoo node.js typescript
2022-01-06 - Originally posted at https://www.grouparoo.com/blog/typescript-types-from-class-properties
↞ See all posts
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:
1import { Action } from "actionhero"; 2 3export class TestAction extends Action { 4 constructor() { 5 super(); 6 this.name = "testAction"; 7 this.description = "I am a test"; 8 this.inputs = { 9 key: { 10 required: true, 11 formatter: stringFormatter, 12 validator: stringValidator, 13 }, 14 value: { 15 required: true, 16 formatter: integerFormatter, 17 }, 18 }; 19 } 20 21 // <--- Note the type definition below for `params` 22 async run({ params }: { params: { key: string; value: string } }) { 23 return { key: params.key, value: params.value }; 24 } 25} 26 27function stringFormatter(s: unknown) { 28 return String(s); 29} 30 31function integerFormatter(s: unknown) { 32 return parseInt(String(s)); 33} 34 35function stringValidator(s: string) { 36 if (s.length < 3) { 37 throw new Error("inputs should be at least 3 letters long"); 38 } 39}
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?
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:
1abstract class Greeter { 2 abstract greet(who: string, message: string): void; 3} 4 5class ClassyGreeter extends Greeter { 6 greet(who, message) { 7 console.log(`Salutations, ${who}. ${message}`); 8 } 9} 10 11const classyGreeterInstance = new ClassyGreeter(); 12classyGreeterInstance.greet("Mr Bingley", "Is it not a fine day?"); // OK, inputs are strings 13classyGreeterInstance.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:
1class ConstructedList { 2 items: string[]; 3 4 constructor() { 5 this.items = ["apple", "banana"]; 6 } 7} 8 9class StaticList { 10 items: ["apple", "banana"]; 11} 12 13typeof ConstructedList().items; // string[] 14typeof 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
.
ParamsFrom
Type UtilityKnowing 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:
1export type ParamsFrom<A extends Action> = { 2 [Input in keyof A["inputs"]]: A["inputs"][Input]["formatter"] extends ( 3 ...ags: any[] 4 ) => any 5 ? ReturnType<A["inputs"][Input]["formatter"]> 6 : string; 7};
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:
1import { Action, ParamsFrom } from "actionhero"; 2 3export class TestAction extends Action { 4 name = "testAction"; 5 description = "I am a test"; 6 inputs = { 7 key: { 8 required: true, 9 formatter: stringFormatter, 10 validator: stringValidator, 11 }, 12 value: { 13 required: true, 14 formatter: integerFormatter, 15 }, 16 }; 17 18 async run({ params }: { params: ParamsFrom<TestAction>) { 19 return { key: params.key, value: params.value }; 20 } 21} 22 23function stringFormatter(s: unknown) { 24 return String(s); 25} 26 27function integerFormatter(s: unknown) { 28 return parseInt(String(s)); 29} 30 31function stringValidator(s: string) { 32 if (s.length < 3) { 33 throw new Error("inputs should be at least 3 letters long"); 34 } 35}
And finally, we can see our params are typed:
We contributed this work back to Actionhero, and in Actionhero v28.1.0, the ParamsFrom
utility is included!
I write about Technology, Software, and Startups. I use my Product Management, Software Engineering, and Leadership skills to build teams that create world-class digital products.
Get in touch