Typescript stories - Generics vs Union types

Typescript stories - Generics vs Union types

Who's the winner?

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

ScenarioUnion TypesGenericsGenerics + 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…