Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aledpardo/9fe3e0ce47ed595a0dd7242d80cac3fa to your computer and use it in GitHub Desktop.
Save aledpardo/9fe3e0ce47ed595a0dd7242d80cac3fa to your computer and use it in GitHub Desktop.
Using Typescript union types effectively

Using Typescript union types effectively

A hypothetical REST API

Suppose a REST API with the following request patterns:

Correct usage:

GET /books/?authors=JRRTolkien,CSLewis
200 OK

GET /books/?editors=delrey,britannica
200 OK

Incorrect usage:

GET /books/?authors=JRRTolkien,CSLewis&editors=delrey,britannica

400 Bad Request

For some reason, we can't mix authors and editors query params.

How could we design Typescript types and interfaces to support this behavior?

Defining Typescript types - Naive way

We're interested on a design that will translate the Rest API requirements, enforcing the point that we can't request with both params at the same time.

Here's a naive approach:

interface ByAuthorsFilter {
  authors: Array<string>;
}

interface ByEditorsFilter {
  editors: Array<string>;
}

/**
 * Resolves if response is OK 200, otherwise throws Error
 */
function getBooks(filters: ByAuthorsFilter | ByEditorsFilter): Promise<Books>;

Ok, we're doing it right, correct? filters may be either by authors or by editors, so the type for filters argument will do it!

Well, in reality, that's not right.

What filters type is telling us, is that filters can have properties from both ByAuthorsFilter or ByEditorsFilter. So, below objects will be understood as correct by Typescript compiler:

const byAuthors: ByAuthorsFilter = {
  authors: ["JRR Tolkien", "CS Lewis"]
};

const booksByAuthors = await getBooks(byAuthors); // Compiles and runs OK


const byEditors: ByEditorsFilter = {
  editors: ["Del Rey", "Britannica"]
};

const booksByEditors = await getBooks(byEditors); // Compiles and runs OK

Then, a developer using these interfaces might, inadvertently, compose to the Books API filters with incorrect params for the request, but correct as per the types.

const byBoth: ByAuthorsFilter | ByEditorsFilter = {
  authors: ["JRR Tolkien", "CS Lewis"],
  editors: ["Del Rey", "Britannica"]
};

const booksByBoth = await getBooks(byBoth); //  // Compiles but results in runtime error as the underlying API doesn't support both filters

Defining Typescript types - the copy/past approach

Well, now we can assert that, our Books REST API interface supports requests by authors and by books, but not both.

So, our naive approach fell short.

Another approach I can see developers using is to create specialized functions, like:

function getBooksByAuthors(filters: ByAuthorsFilter): Promise<Books>;

function getBooksByEditors(filters: ByEditorsFilter): Promise<Books>;

function getBooks(filters: ByAuthorsFilter | ByEditorsFilter): Promise<Books>;

But, then we have some level of duplication. And might not be desirable at times.

Are we out of options here?

I wonder whether, perhaps developers using another strong typed language, for example Java, or C#, would use method overloading at the first attempt.

Defining Typescript types - The good ol' OOP fashion

Typescripts docs on overloading functions and methods

Lucky us, Typescript does support function/method overloading. Which is very nice, but the way we write it might be seem as odd, here's some references:

Typescript function overloading applied

Here's how we could define types for our Books REST API example, in a supportive way for developers to compose always the correct request.

interface ByAuthorsFilter {
  authors: Array<string>;
}

interface ByEditorsFilter {
  editors: Array<string>;
}

/**
 * Resolves if response is OK 200, otherwise throws Error
 */
function getBooks(filters: ByAuthorsFilter): Promise<Books>
function getBooks(filters: ByEditorsFilter): Promise<Books>
function getBooks(filters: ByAuthorsFilter | ByEditorsFilter): Promise<Books> {
  // implementation
}

With above types, if we try to call getBooks with the wrong interface, the Typescript compiler will warn us on the spot:

const byAuthors: ByAuthorsFilter = {
  authors: ["JRR Tolkien", "CS Lewis"]
};

const booksByAuthors = await getBooks(byAuthors); // Compiles OK


const byEditors: ByEditorsFilter = {
  editors: ["Del Rey", "Britannica"]
};

const booksByEditors = await getBooks(byEditors); // Compiles OK

const byBoth: ByAuthorsFilter | ByEditorsFilter = {
  authors: ["JRR Tolkien", "CS Lewis"],
  editors: ["Del Rey", "Britannica"]
};

const booksByBoth = await getBooks(byBoth); // Compiler Error

Conclusion

Most of time, we can leverage Union Types to handle multiple ways to call a given function or method.

However, when we the target function can only be called with one of the types at a time, we can leverage function/method overloading as per OOP principles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment