Typescript stories - Generics vs Union types
Table of contents
This will be a quick and more “back-to-basics” kind of article.
While doing some small refactoring, I stumbled across a situation where I had to pass an object, but the shape of the object is completely different depending on the component using that method.
Let me draw.io you the problem
Objective - Find who is type and not use the mighty foe - any
.
If any
was an accepted solution in my book, this article wouldn’t exist. This is why constraints make us creative.
Marker interface
One of my first instincts was to use a marker interface. I don’t need functionality for the type; it’s just an object, but somehow I want to narrow down the type of objects I’m sending.
What’s a marker interface? It’s basically an interface with no methods or properties. You’re using it just for enforcing type checks.
/// Marker interface
interface IVendorForm {}
interface Vendor0FormData {
/// Doesn't extend FormData
}
interface Vendor1FormData extends IVendorForm {
name: string,
surname: string
}
interface Vendor2FormData extends IVendorForm {
location: string
coord_x: number
coord_y: number
}
const sendData = (formData: IVendorForm) => {
/// Send data
}
The university me would have said - Neat solution!
The present me is saying - Why?
If it doesn’t have a functionality, why would it exist in the first place? (enter the existential crisis).
Generics
What are Generics?
Generics allow you to define functions, classes, or interfaces with a type parameter that you can specify when you use them. This provides reusability and type checking with minimal drawbacks.
Let’s refactor the sendData
method a bit:
const sendData = <T>(formData: T): void => {
/// Send data
}
sendData<Vendor1FormData>(form)
sendData<Vendor2FormData>(form)
Easy peasy! But wait a second…
Now I can send whatever I want to sendData
. I’m back to square one. I have no guard and no way to narrow down the type I want to use. I just did any
with extra steps!
Union types
In TypeScript, we have what are called union types, which can hold a set of different types. In our scenario, we might have something like this:
type VendorForm = Vendor1FormData | Vendor2FormData;
interface Vendor1FormData {
type: 'vendor1'
name: string;
surname: string;
}
interface Vendor2FormData {
type: 'vendor2'
location: string;
coord_x: number;
coord_y: number;
}
const sendData = (formData: VendorForm) => {
if (formData.type === "vendor1") {
console.log(formData.name)
}
if (formData.type === "vendor2") {
console.log(formData.location)
}
};
Ok, we’re going places. Now I’ve narrowed the types that sendData
can receive. It’s flexible enough for our scenario. The difference between the types is usually done through a discriminator.
I recommend having some sort of type discriminator instead of relying on parameters. Indeed, you have a parameter that you carry but then you have a simple logic.
If there’s another vendor form type, we can simply add a new interface for it and include it in the union type.
In most scenarios, union types should do the trick. They aren’t as flexible as generics but are more strict.
Generics AND Union types?
Often, the best solution is found in the intersection. The same principle applies here.
There is a specific scenario where combining generics with union types creates a great developer experience when it comes to inference.
If, in our scenario, we would need to return the same type that we passed to the function, only the generics solution would maintain the inference correctly.
If we used a union type, the returned type would be the union itself, not the specific interface that was passed in. This results in a loss of inference.
Using both generics and union types is the sweet spot between the flexibility and type safety that we need.
Summary
Let’s wrap up with a quick recap of what we’ve learned based on the scenarios discussed:
If we’re dealing with a fixed set of types
Reusability with strict type constrains
Returning the exact input type
Scenario | Union Types | Generics | Generics + Union Types |
Fixed set of types | ✔️ Great for type narrowing | 🚫 Overkill for fixed types | ✔️ Does the job |
Reusable with strict type constraints | 🚫 Limited to specified union | ✔️ Works for any type | ✔️ Flexible with constraints |
Returning exact input type | 🚫 Returns general union | ✔️ Maintains exact type | ✔️ Maintains exact type |
Understanding when to use union types, generics, or a combination of both allows us to achieve the perfect balance of flexibility, reusability, and type safety.
PS: How about interface markers? Not today…
Subscribe to my newsletter
Read articles from Mihai Oltean directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Mihai Oltean
Mihai Oltean
Software engineer / Angular / C#