NEVERBLAND®
02.12.2019
Originally posted at Medium
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?
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}`
};
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.
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).
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});
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({
…works as well. Problem solved!
id: '123',
cost: 10,
type: 'horse',
currency: '£',
});
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.
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.
4 min read