- Published on
Explicit return types
- Authors

- Name
- Jacek Smolak
- @jacek_smolak
Word about APIs
Good APIs are versioned (whatever good means). And if so, they should (not guaranteed) retain the interface. They should honor the open-closed principle: "software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification". Meaning, if some REST API extends (adds new fields to) the data structure it returns, then this is just an extension, not a breaking change.
One more thing to remember - such APIs are consumed by third parties. Changing things without versioning can, and will, result in problems. So it's better to keep to versioning rules, e.g. semver. But this post is not about versioning. The responsibility that comes with breaking changes is. Having that said...
What about exported functions, and public methods?
Let's have a look at this exported function:
export const getProducts = async () => {
// This is an example, not using any well-known client.
// The generic set is supposed to tell TS that `products` is of type Product[]
const { products } = await httpClient.get<Product[]>('/products')
// So that this function returns Promise<Product[]>
return products
}
This implementation will make the IDE tell us what the return type is, everyone will see it immediately, etc. Cool.
The problem
What if someone will change it? Let's say, we had to have pagination data, because of reasons. This function would look something like this:
// The name needs to change as well, bugger
export const getPaginatedProducts = async () => {
const { products, metadata } = await httpClient.get<Product[]>('/products')
const paginationData = createPaginationData(metadata)
return { products, pagination: paginationData }
}
Now, that function returns Promise<{ products: Product[], pagination: PaginationData }>. Of course, typescript will see that and will highlight all the places you need to amend.
But you have introduced a breaking change.
If you work alone on an app, or in a small team, this is not a problem. But if you work in a large codebase, like a monorepo, and many teams, tens or hundreds of developers work on that codebase, this change can create a cascade of unwanted work. And that is a major problem.
Suggested approach
Whenever you find yourself in need of changing such a code, this is what you should keep in mind:
- the return type for the function was designed like that for a reason
- and as such, won't that break somebody else's implementation?
- also, do I really want to change this?
- and if so, am I changing it in the right place?
As we can't version functions (well... sure, perhaps getProducts2 😉 just kidding), we could make sure the returned type (as well as the arguments interface) are intact within the open-closed principle boundaries. That is why I would suggest the following:
Introduce an explicit return type for all public functions/methods
This will give you the following benefits:
- keeping you in check, making you reason about the existing implementation; for instance: won't I violate SRP if I add that change?
- IDE will tell you what parts of the codebase will be affected, before you start implementing the change, giving you a cheap and quick way to see if this is a reasonable change and does not affect others in a bad way
- you will have a TS error till you complete the change to honor the interface and will tell you when you're done; think of it as a constant feedback loop
- you could spot a code smell which might lead you to a better implementation
- explicit, readable returned interface, one that anyone can inspect (vs implicit one, e.g. from yet another function call, or a cascade of calls)
- and so on...