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?
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
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.
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:
- The Typescript Handbook - Functions Overloads
- Declaration Types - Overloaded Functions
- Do's and Dont's - Function Overloads
- Do's and Dont's - Overloads and Callbacks
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
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.