Generic Type Inference through Function Arguments in TypeScript

Jacob Clark
5 min readDec 17, 2021

Occasionally you’ll find yourself in a situation where untyped data enters your system through a uniform entry point, and you’re left with the challenge of mapping it into types within your application.

Consider a webhook driven system where a client calls a single endpoint with multiple types of data, where the type of data is indicated by a property of the payload.

For example:

POST: /message 
BODY: {
type: "TextMessage",
userId: 123,
message: "Hello World"
}

and

POST: /message
BODY: {
type: "ImageMessageWithText",
userId: 123,
message: "A picture of my cat"
imageUrl: "http://catpicture.png"
}

If we ignore the fact that /message should likely be split up into multiple endpoints /message/text and /message/imageMessageWithText or the type of data hinted through metadata such as HTTP Headers, we’re faced with an interesting decision on how we deal with the complexity these integrations can have on a typed program. It is often the case that changing how the calling system works is out of our control, but that doesn’t mean our solution to handling these requests has to be unwieldily.

This post will discuss how to deal with this situation in a maintainable and type safe way using Generic Type Inference through Function Arguments in TypeScript.

Firstly, within our system, we need to define our types based on the known data sent by the calling system, our domain is Message and we have two distinct types of message TextMessage and ImageMessageWithText .

interface Message {
type: string;
userId: number;
}
class TextMessage implements Message {
type: string;
userId: number;
message: string;
}
class ImageMessageWithText implements Message {
type: string;
userId: string;
imageUrl: string;
message: string
}

Consider an imaginary web framework for Node.js that handles the /message endpoint in TypeScript as the following:

@Post
@Controller('/messages')
function message(request: Request) {
const payload: any = JSON.parse(request.payload);
}

Given we already get a hint as to the contract through the payloads type property, we can use ClassTransformer to map our Plain Old JavaScript Object to a concrete class:

@Post
@Controller
function message(request: Request) {
const payload: any = JSON.parse(request.payload);
const message: Message = plainToClass(TextMessage, payload);
}

However, this alone won’t suffice, as it stands right now, if we were to receive an ImageWithText type, our message object would be missing the image property, as plainToClass will drop properties from an object during mapping if they’re not defined within the class definition. As we mapped a RequestWithImage to a TextMessage type, we lost data, we need to go further…

We could solve this through conditionals, like so:

@Post
@Controller(“/messages”)
function message(request: Request) {
const payload: any = JSON.parse(request.payload);
let mappedMessage: Message;

if(payload.type === "TextMessage){
mappedMessage = plainToClass(TextMessage, payload);
}
if(payload.type === "ImageMessageWithText"){
mappedMessage = plainToClass(ImageMessageWithText, payload);
}
}

This is the simplest approach. We can explicitly type what plainToClass returns, we can determine which Class to pass plainToClass for mapping explicitly.

However, this solution is not particularly scalable, especially in an evolving codebase that may expand to support further Message types in future. This implementation will quickly become a burden to maintain and test. We can simplify with the refactoring pattern Replace Conditional with Polymorphism in order to improve the legibility, testability and maintainability of our code.

In some circumstances you could create a function that calls plainToClass, where an argument to the function is the Class to be mapped too, it is preferred however to depend on type safety to ensure program correctness, and relying on passing a Class as an argument foregoes type safety and would result in excessive use of the base type or the any type. Equally, TypeScripts reflection abilities are considered poor and as a result plainToClass does not support generics, which without explicit typing would result in our program struggling to infer types further down the line.

This is where we need Generic Type Inference through Function Arguments to help us dynamically map the string type we get within our payload to the actual Class implementation for that type.

We firstly need to introduce a map that points the string type to the actual class type we care about:

const messageTypeToClassMap = {
"TextMessage": TextMessage,
"ImageMessageWithText": ImageMessageWithText
}

Now consider a refactor of our POST Controller for the /message endpoint to remove our conditional into a more Generic Type Inferring solution:

function mapPayloadToClass<T>(
messageClass: { new(): T; },
payload
): T {
return plainToClass(messageClass, payload);
}
@Post
@Controller(“/messages”)
function message(request: Request) {
const payload: any = JSON.parse(request.payload);
let mappedMessage: Message = mapPayloadToClass(messageTypeToClassMap[payload.type]);
}

We’ve successfully removed our conditional by relying on a Plain Old JavaScript Object that points our string type to our actual Class implementation, using the type to yank the Class implementation from the map and subsequently passing this type to our mapPayloadToClass in order to support ClassTransformer in constructing the correct object.

As the codebase grows and the number of Message types increase, code duplication does not, the only change required to register a new concrete type within the system is to update the messageTypeToClassMap.

A strongly typed, dynamic solution to a problem which would otherwise require unwieldy conditional branches or a switch statement.

The syntax above is a bit complex, so we can break it down:

1. function mapPayloadToClass
2. <T>
3. (messageClass: { new(): T; }, payload: any)
4. : T

Line 1 is our declaration of the mapPayloadToClass function, you’ll be familiar with this.

Line 2 is our generic type parameter list, stating we have a generic type named T that will be defined at call time.

Line 3 is the parameters our mapPayloadToClass function accepts. There are 2 arguments listed, messageClass and payload . The messageClass argument is defined as type { new(): T; } which simply states messageClass is an Object where theconstructor returns a type of T . We already know type T must be a class that extends Message , so we can deduce here that messageClass is a Class definition that conforms to our generic type this function is typed against.

Line 4 is the return type of this function, namely something that conforms to type T, or in other words, a Class that extends Message .

The result of this code is the ability to branch the implementation of some function purely based on the type inputs to said function, omitting entirely the complexities, code duplication and maintenance overhead explicit conditionals/branching controls bring.

Evidently it would be far better to re-design the API to have explicit endpoints per each type of Message that can be received, but that isn’t always possible and there are likely to be many situations where you’re relying on some value, probably a string, to determine how to construct concrete objects.

Have the language do the heavy lifting so you don’t have too.

We can put the entire solution together:

interface Message {
type: string;
userId: number;
}
class TextMessage implements Message {
type: string;
userId: number;
message: string;
}
class ImageMessageWithText implements Message {
type: string;
userId: string;
imageUrl: string;
message: string
}
const messageTypeToClassMap = {
"TextMessage": TextMessage,
"ImageMessageWithText": ImageMessageWithText
}
function mapPayloadToClass<T>(
messageClass: { new(): T; },
payload
): T {
return plainToClass(messageClass, payload);
}
@Post
@Controller(“/messages”)
function message(request: Request) {
const payload: any = JSON.parse(request.payload);
let mappedMessage: Message = mapPayloadToClass(messageTypeToClassMap[payload.type]);
}

--

--