TypeScript: Generic Function Parameter Types

NEVERBLAND®

NEVERBLAND®

02.12.2019

Originally posted at Medium

TypeScript: Generic Function Parameter Types

I’ve bundled some lovely example-code here

Modifying object properties in TypeScript/JavaScript is some of the first things we learn as programmers. We can directly modify the property (foo.bar = ‘test’) or create a shallow copy (const shallowFoo = {…foo, bar: ‘test’}). A quick reflection reveals that most often, modifying objects like this is done with some sort of business logic behind it. There is, after all, a reason why we desire to update object properties. By moving this logic into a helper function, we can easily test and reuse the code to stay DRY (don’t repeat yourself, The Pragmatic Programmer [A. Hunt, D. Thomas]). But a new challenge arises: how do we keep these helper function generic while giving it a single, clear purpose?

The problem

To better describe the problem, this article will work with a fictional example, where we are going to modify id’s on incoming objects.

This example will add a prefix to all id’s coming from the API in order to distinguish them from id’s generated by a framework our application is using. The interface we will use, in this example, is describing a product containing two properties; id and price.

interface ProductData {
id: string;
price: number;
}

Let’s retrieve the data and add the prefix “product-id: ” to the id property:

const productData: ProductData = {id: '123', price: 10};const mappedProductData = {
...productData,
id: `product-id: ${productData.id}`
};

"`product-id: ${productData.id}` is a string literal. Read more about it here."

This successfully mapsProductData to its preferred format. At a brief glance, this looks fairly good. But the functionality is not reusable and encourages copy-past behaviour, breaking the mantra: DRY. This can be solved by breaking out the business logic into a separate function, making the code more reusable.

Basic reusable function

By breaking out the logic into a separate function so, the code becomes easily reusable, and we programmers can assure the product owners that the id property will always change in the same way.

function mapProductData(productData: ProductData) {
return {
...productData,
id: `product-id: ${productData.id}`
};
}

And then we can call the function:

const mappedProductData = mapProductData({id: '123', price: 10});

This creates a reusable function that got a single, clear purpose — to add a prefix to the id property. But there are a couple of problems with this solution. Firstly, the object type ProductData is lost and the function returns the object with an unnamed interface:

Secondly, the function is forced to always use the ProductData shape as the parameter type. If not, TypeScript might give you a linting error. Or worse: we get out of sync with our interfaces (See ExampleA3 in the example code).

The solution

In order to proceed, we have to rethink the approach for the function’s parameters. So far, we’ve used a fixed type for the parameters: an object that requires both the id and price property. But the performed mapping only requires the knowledge about the id property. By describing this required minimal shape in TypeScript, a more accurate contract can be created for the function. This can be described as:

interface MinimumProductData {
id: string;
}

In order to make the mapping function more generic, the function has to accept any object that at least fulfils MinimumProductData. We can do that by using TypeScript’s generic types.

This is where things get a bit tricky. Let’s first quickly brush up our knowledge on generics. In a function deceleration, generics are defined after the function name as: <T>.

function foo<T>() {
...logic goes here
}

T can now be used within the function itself, its parameters and/or return type. In order to automatically assign T to a type, we will define our parameter to be of type T. TypeScript will then automatically grab the type from the function arguments and assign it to T.

function foo<T>(input: T) {
...logic goes here
}

So, whatever type the arguments got when calling foo, T will assume. Input of type string will force T to be of type string. Input of type object and T will be of type object. And so on…

foo('bar'); // And T will be of type string.

Back to our example and we do not want to receive a string to our mapper function mapProductData. We don’t even want a generic object. We want an object that has at least the shape MinimumProductData (with property id).

This is where we are going to utilize TypeScripts’ keyword extends. Extends will make sure that our generic type is at least a given shape. For mapAnyProductData, we want the generic type to at least contain MinimumProductData. Let’s name the generic TProduct. This gives us <TProduct extends MinimumProductData> and the function can accept any input object as long as we can find the property id on it.

function mapAnyProductData<TProduct extends MinimumProductData>(anyProductData: TProduct) {
return {
...anyProductData,
id: `product-id: ${anyProductData.id}`
};
}

And the usage is simply unchanged from before:

const mappedProduct = mapAnyProductData({id: '123', price: 10});

"Note that the return type is left empty. It might be tempting to type the return, but being lazy seems to be the best option here. TypeScript will namely do the work for you and type the return. This removes unnecessary code, and makes it slightly easier to read, especially when we move into more complex extensions."

This way, we can reuse this logic across the application. And not only with a particular object type, but with all objects that contain the property id. So…

const mappedProduct = mapAnyProductData({
id: '123',
cost: 10,
type: 'horse',
currency: '£',
});
…works as well. Problem solved!

Extend an Immutable Object

So what if we have an immutable object that we want to add extend? We can use the same methodology.

interface MinimumProductData {
price: number;
}

function mapProductData<TProduct extends MinimumProductData>(productData: TProduct) { return { ...productData, priceWithVat: productData.price * 1.2 }; }

const mappedProductData = mapProductData({price: 10, type: 'car'}); console.log(mappedProductData.priceWithVat); // 12

The only difference here will be that the returned shape will be defined as:

MinimumProductData {
price: number;
type: string;
} & {
priceWithVat: number;
}

The function now can take any type of objects, as long as they contain the price property, and it will return the same type with the extension of priceWithVat. This successfully shows a way of extending an object, containing the business logic intact and removing the need to repeat ourselves. This is a good way forward when building scalable products in TypeScript.

"Note that TypeScript, when inspected, will keep the original and the extension types visually separated with the ampersand character (&). This simply strives back to the fact that mapProductData got an unnamed return type. But if we would go throug the troubble of typing our function, we would be required to know the full size of the incoming object and therefore the function can no longer be generic. We simply have to stick to the fact that this is a new way of seeing extended types in our application. A small tradeoff, in my opinion, to achieve reusable, single-purpose functions that modifies objects."

Summary

So, this article has covered how to generically amend an object, defined by using TypeScripts’ generic types. We’ve covered that extends keyword can describe a minimal shape for function parameters in TypeScript. And we’ve shown that breaking out business logic into a reusable, single-purpose function helps to create a more scalable application.

Hope you enjoyed this article. Feel free to leave a comment, share or like. Maybe you have some great ideas that you’d like to share. Or just have a question. Looking forward to hear your thoughts.

Viktor

More like this

Next up...

Stay in the loop

Sign up to get our thoughts, work and other tidbits straight to your inbox. No spam, promise.