Conditionally define TypeScript types using data
2021-12-16 15:35:05 +0000 UTCPremise
TypeScript Conditional Types are an awesome way to make some really powerful types based on other types in your code.
However, sometimes it is useful to create types based on actual data in your code, rather than type definitions.
What do I mean when I say data? Let’s look at an example
const a = 'a'
const b = 'b'
// this does not work because `a` and `b` cannot
// be used in the conditional for a Type
type C = a === b ? string : boolean
The above example is trivial, but this pattern can be very useful when defining APIs for libraries. For example, the arguments for a method can be typed conditionally based on other argument values. As an example, let’s consider a trivial method which concatenates strings to build a “meal”:
type Food = "burger" | "pasta";
type Condiment = "mayo" | "mustard";
type Sauce = "marinara" | "pesto";
function buildMeal(food: Food, topping: Condiment | Sauce) {
return [food, topping].join(" with ");
}
This works just fine, but it accepts combinations that you may not want to allow, such as
buildMeal("burger", "marinara");
buildMeal("pasta", "mayo");
Wouldn’t it be nice if we could constrain the topping
argument to only allow a Condiment
when food === 'burger'
and only allow a Sauce
when food === 'pasta'
?
Turns out, we can! Here’s a simple example of how to do it - if you want to know more, read on!
// define classes for the different types of food
class Burger {
name = "burger"; // `name` is data; note the `=`
topping: "mayo" | "mustard"; // `topping` is a type; note the `:`
}
class Pasta {
name = "pasta";
topping: "marinara" | "pesto";
}
// Foods holds references to all our different types of food
const Foods = {
burger: new Burger(),
pasta: new Pasta(),
} as const;
type Food = keyof typeof Foods;
function buildMeal<T extends Food>(
food: T,
topping: typeof Foods[T]["topping"]
) {
// You could use the `food` argument directly, or
// you can reference data on the class like this.
const name = Foods[food].name;
return [name, topping].join(" with ");
}
(If the above gif doesn’t load for you, check out the direct link)
Background
At Quin, we use a monorepo powered by Nx to develop our primary API, PWA frontend, and serverless components (you can read more about our tech stack here). To bind all these together we have a substantial number of internal libraries we’ve written that handle everything from wrapping third-party APIs, to formatting event names (with the goal of better adherence to a standard event naming framework across our codebase).
Internal libraries are one of the most potent areas to impact the quality of your code. A thoughtful, easy-to-use library API can make it a breeze to implement functionality in disparate call sites across many services.
The problem
A struggle that we have run into a few times when working with third-party APIs is the combination of data and type definitions. For example, let’s say you want to interact with AWS SQS by pushing messages to a queue. Typically you will want at least two things
- The
QueueUrl
of the SQS queue (data) - A contract for the shape of the data you are sending, so that consumers may know what to expect (types)
For a single queue, this is easily accomplished by defining the QueueUrl
in some variable, and the type definition in a type
or interface
. But if the library is intended to interface with SQS in a generic way, how do you enforce types on the data while conveniently accessing the data (QueueUrl
) that is required to make it work?
Sample code
export class InternalSqsClient {
// ... instantiate AWS SDK, etc
async sendMessage(queueUrl: string, message: unknown) {
// how do we force message to conform to a known type?
// how do we make the message `type` dynamic based on
// the queueUrl value?
// (i.e. different queues will likely expect different contracts)
}
}
Before you point out the obvious: yes, the AWS SQS SDK expects a string
type for the MessageBody
argument. The point here is that the library should be able to enforce a type definition for callers - stringifying an Object is trivial and should be delegated to the library anyway.
A naïve solution
The simplest way to approach this problem is by putting the burden on the caller to know both the data and the type they need. A trivial example might look something like this (using the above defined InternalSqsClient
)
const client = new InternalSqsClient();
await client.sendMessage(process.env.PAYMENT_QUEUE, {
id: "my id",
amount: 42.0,
});
// somewhere else in the code
await client.sendMessage(process.env.EMAIL_QUEUE, {
to: "[email protected]",
body: "welcome",
});
This works but has the unfortunate effect of requiring the caller to know
- The data required to interact with SQS, and
- The expected data structure for the message they are sending
We have to ask: is there much benefit to our custom wrapper if the caller still needs to know implementation details of the API with which they are interacting?
A better way
TypeScript offers a plethora of ways to create and reference types based on both existing data and other types. However, there are very few structures that allow combining both data (e.g. a string literal such as a QueueUrl
) with type definitions.
One way we can combine our data and types in a single unit of code is with a class
.
For example, if we have two queues as shown in the code above, we might define two classes in our internal library like this
// a class allows us to combine both data and types in one construct,
// which we can reference in our method to constrain the type
// definition, as well as access the data from the class
class PaymentQueue {
url = process.env.PAYMENT_QUEUE; // `url` is data; note the `=`
message: {
// `message` is a type; note the `:`
id: string;
amount: number;
};
}
class EmailQueue {
url = process.env.EMAIL_QUEUE;
message: {
to: string;
body: string;
};
}
// `Queues` is just a map of queue names to the queue "definitions",
// which in this case contain the QueueUrl (url) and message type.
// Note: the keys of `Queues` can be anything! They are intended
// to be useful, descriptive names that will be referenced by the caller
const Queues = {
Email: new EmailQueue(),
Payment: new PaymentQueue(),
} as const;
We can then exploit some TypeScript cleverness to define our arguments for the sendMessage
method
// Queue is now a union of the keys of Queues; specifically
// 'Email' | 'Payment'
type Queue = keyof typeof Queues;
// Define a config object
type SendMessageConfig<T extends Queue> = {
queue: T;
// enhancement: you might want your classes to each
// implement the same interface, to ensure this always works
message: typeof Queues[T]["message"];
};
export class InternalSqsClient {
// the method must accept a generic which gets resolved when the
// user enters a value for `queue`
async sendMessage<T extends Queue>(config: SendMessageConfig<T>) {
// this property access works because
// `url` is data defined on the class
const queueUrl = Queues[config.queue].url;
const message = config.message;
// call SQS SDK with our data
}
}
Benefits
- Callers never need to know anything about the API - any TS editor will auto-complete the available names for
queue
- Callers will be forced to adhere to a specific structure for their
message
properties, and the enforced structure is dynamic based on theirqueue
Example calling the new improved API
const client = new InternalSqsClient();
await client.sendMessage({
queue: "Payment",
message: {
id: "my id",
amount: 42.0,
},
});
await client.sendMessage({
queue: "Email",
message: {
to: "[email protected]",
body: "welcome",
},
});
Much nicer!
(If the above gif doesn’t load for you, check out the direct link)
The uglier alternative
The savvy reader may be thinking “You haven’t shown us anything new – this is entirely possibly using Conditional Types!”
You’re correct! Here’s an example of doing the same thing using pure conditional types
type PaymentMessage = {
id: string;
amount: number;
};
type EmailMessage = {
to: string;
body: string;
};
type Message<T extends Queue> = T extends "Payment"
? PaymentMessage
: EmailMessage;
Indeed, this works just fine. But what about for an arbitrary number of Queues? There are probably codegen tools that would make your life easier, but there is no general solution without manually adding a bunch of conditionals, e.g.
type Message<T extends Queue> = T extends "Payment"
? PaymentMessage
: T extends "Email"
? EmailMessage
: T extends "AddressUpdate"
? AddressUpdateMessage
: T extends "LinkAccount"
? LinkAccountMessage
: GenericMessage;
This solution might be the right choice in certain cases but I don’t think it is as scalable, or as easy to read. Disagree? Let me know if the comments!
Another alternative could be to define you data and types in two places. This works, but has the unfortunate side-effect of de-coupling your data and types, rather than encapsulating them in one class. For example
const QueueUrls = {
Payment: process.env.PAYMENT_QUEUE_URL,
Email: process.env.EMAIL_QUEUE_URL,
} as const;
type QueueMessageTypes = {
Payment: {
id: string;
amount: number;
};
Email: {
to: string;
body: string;
};
};
You can use a very similar pattern as above to reference the correct types, but it is more brittle because the top-level properties of QueueMessageTypes
must exactly equal the keys of QueueUrls
or it won’t work. Duplicating code leads to more possibilities of typos and bugs.
Conclusion
TypeScript Conditional Types are great for building types from existing type definitions. But they do not allow you to use data to make decisions. By using some TypeScript magic we can define conditional types using real data in our application. Some benefits:
- Declarative, encapsulated definitions for library values
- Hide implementation details from callers
- Expose useful, descriptive argument options to callers
- Create generic APIs that can support a wide range of behaviors while enforcing correct static typing in all scenarios
Happy coding!