Intro
In the React world, where object-oriented programming isn’t as prominent, and where methods that manipulate object state are less common, finding existing utility functions can sometimes feel like a treasure hunt.
Even when I try to be consistent with naming conventions, I often find myself lost in a sea of calculateTotalAmount
, getTotalAmount
, and calculateTotal
. Multiply this problem by several developers—and frequent team rotations—and you get an exponentially growing mess.
But don’t get me wrong, I love a well-curated set of utility functions. Solving a problem means breaking it down into smaller problems, and those smaller problems often lead to reusable utilities. The goal? A solid arsenal of helper functions that make our codebase clean and efficient.
The Initial Solution: Decorating Objects
The Decorator Pattern initially seemed like a great way to attach utility functions directly to JavaScript objects. In the app I was developing, domain objects were key players, and since they were usually served from the backend, decorating them in transformResponse
when using RTK-query seemed like a natural choice.
To avoid issues with serialization, you can use defineProperties
and set the property enumerable
to false
:
const calculateSum = (obj: SomeObject) => {
// ... do something ...
return sum;
};
const createDecorator = (obj: SomeObject) => {
const instance = { ...obj };
Object.defineProperties(instance, {
[calculateSum.name]: {
value: () => calculateSum(instance),
enumerable: false, // This makes the property non-enumerable
},
});
return instance as InsuranceObjectDecorated;
};
const decoratedObj = createDecorator(someObject);
The Drawbacks
While this approach gives us quick access to utilities, it comes with some major drawbacks:
- Redundancy – You need to decorate the object on every endpoint that fetches it.
- Type Confusion – A decorated version introduces a new type, creating inconsistencies within the repo.
- Autocomplete Woes – TypeScript’s structural typing means that the decorated version is valid as the non-decorated version, but you lose autocomplete benefits.
- Cascading Updates – Updating types results in a ripple effect of changes throughout the codebase.
A Better Approach: Functional Decoration
I love having utility functions at my fingertips, but the drawbacks above were deal-breakers. So I took a step back and rethought the approach.
Key Principles:
- No adding methods to objects.
- No sneaky prototype hacks.
- No changing method return or parameter types.
- Works with both primitives and objects.
Instead of modifying objects, we wrap them in a utility layer only when needed:
function decorate(object: TypeA): DecoratedTypeA;
function decorate(object: TypeB): DecoratedTypeB;
function decorate(object: unknown) {
if (isTypeA(object)) {
return {
calculate: () => calculate(object),
sumUp: () => sumUp(object),
format: (locale: string) => format(object, locale),
};
}
if (isTypeB(object)) {
return {
subtract: () => subtract(object),
close: () => close(object),
findUser: (userId: string) => findUser(object),
};
}
throw new Error("Cannot decorate object");
}
Function overloading
Notice the stacked function signatures at the beginning. This is TypeScript’s function overloading feature. Compare it to:
function decorate(object: TypeA | TypeB): DecoratedTypeA | DecoratedTypeB;
With the latter, your IDE won’t know whether it’s dealing with DecoratedTypeA
or DecoratedTypeB
, killing autocomplete. Function overloading ensures that TypeScript correctly infers the type based on the input.
How to Use It
Now, whenever you want to see what utility functions are available for a type, just wrap your object or primitive in the decorate function to enjoy all your wired-up utils being listed out by your autocomplete:
const result = decorate(yourObjectOrPrimitive).<insert you autocomplete here>
Adding utils
Wiring up new utils to existing types or even adding a new type is a breeze! Just follow the steps below as needed
// Create a new Decorated type if you are adding a completely new type to the decorator
type TheNewTypeDecoratedType = {
// Add your new utils to the decorated type
newUtil: () => string
...existing util function types...
}
// Create a new typeguard if needed
function isTheNewType(val: unknown): val is TheNewType { ... }
// Add the new function signature with the type and its corresponding decorated type
function decorate(object: TheNewType): TheNewTypeDecoratedType;
function decorate(...): ...;
function decorate(object: unknown) {
// Add a new conditional if it is a new type being added to the decorator
if (isTheNewType(object)) {
return {
// Wire up your utils!
newUtil: () => theUtilYouWantToAdd(object),
...existing utils...
};
}
...already added types...
throw new Error("Cannot decorate object");
}
Enhancing TypeScript with Template Literal Types
One weakness of TypeScript is its structural typing, meaning two types that look the same are considered the same. Consider this:
type ObjectId = string;
type UserId = string;
function getSomething(id: ObjectId, id2: UserId);
TypeScript won’t complain if you pass a UserId
where an ObjectId
is expected because both are just string
under the hood.
This is problematic on two levels. Nobody stops you if you accidentally pass UserId
as the first param, and ObjectId
as the second, and there is no way for you to write a type guard that can separate the two. I guess you can already see how this messes with my beloved decorator solution above! You want to get all the utils that deal with ObjectId, but instead, you would only get string functions...
but wait, there is hope! Let me introduce you to ...
Template Literal Types
By leveraging Template Literal Types, we can create distinct, string-based types:
type ObjectId = `objectId-${number}`;
Now, we can create type guards for runtime validation:
function isObjectId(value: unknown): value is ObjectId {
return typeof value === "string" && /^objectId-\d+$/.test(value);
}
Combining with Decorate
With this, we can extend our decorate
pattern to work on primitives and their intended type:
decorate(objectId).getObjectWorth();
function decorate(object: ObjectId): DecoratedObjectId;
function decorate(object: unknown) {
if (isObjectId(object)) {
return {
getObjectWorth: () => getObjectWorth(object),
};
}
throw new Error("Cannot decorate object");
}
Conclusion
By rethinking our approach to utility functions, we: ✅ Avoid modifying objects. ✅ Maintain strong TypeScript inference. ✅ Keep our utility functions accessible without messy type changes. ✅ Enhance runtime type safety with Template Literal Types.
The decorate
function provides a clean and scalable way to attach utilities to objects without sacrificing readability, maintainability, or TypeScript’s powerful type inference.
So, the next time you’re tempted to inject utility methods directly into objects—stop. Try a functional approach instead, and keep your codebase clean and predictable.