Recently I was working on a project that required me to write my own TS module.
The goal was to keep it open for type extensions from the consumer code.
I love writing generic code and actually had a really good time writing a quick draft solution, But eventually I came across a problem regarding the types.
The setup
I had some TS code which looked like this:
// some-core-module.ts
export type AvailableFoods = {
apple: number;
mango: number;
cake: number;
};
And then in another module - my “consumer” code which was using that “Core” code, I had this implementation:
import { AvailableFoods } from '@another/module';
const foodInFridge: AvailableFoods = {
apple: 2,
beef: 5, // <<< Error: '"beef" is not in type "AvailableFoods"'
};
So the problem is shown, I need the AvailableFoods
to be extensible.
And now im thinking how the hell im gonna do this?
The first thing that comes to mind is to use TS generic type to have the AvailableFoods
be extensible like so:
// some-core-module.ts
export type AvailableFoods<MoreFoods> = {
apple: number;
mango: number;
cake: number;
} & MoreFoods;
Even started implementing this, but quickly have realized that its turning out quite bad:
The readability of my code have been drastically dropping (on top of a generic Javascript function which was somewhat advanced already).
The MoreFoods
type got thrown around my code from function to function trying to reach the initial AvailableFoods
declaration and cover all references of that type declaration,
this was becoming a typescript apocalypse emoji of scared screaming face. (or typacalypse!)
Well, don’t get me wrong, this approach is not inherently bad, it was only bad for my use case.
I knew the inevitable have came upon me… I had to… refactor! And do it better this time!
The turning point
There I was, baffling on how could this problem be solved better,
And had an epiphany (yay ^_^)
I recalled that the well-known express library had some feature which allowed a user to extend the library’s internal type definition.. I remember doing it with the body type for the request object.
Now I would definitely would liked going straight to express’s source code and see for myself how they’ve written this so nicely.
But I had no idea what to look for, the only lead I had in the matter was “express body type extend” and thats what I googled!
The third result was helpful: StackOverflow: Extend Express Request object using Typescript
And quoting the answer regarding how to extend Express’s request object body type:
“You want to create a custom definition, and use a feature in Typescript called Declaration Merging.”
I went on to read extensively the typescript documentation for Declaration Merging (as was linked in StackOverflow).
And got answers to other questions I had regarding typescript, like Whats the difference between type
and interface
.
Found out, that besides minor difference in appearance and syntax, the only mechanically meaningful difference was the interface’s ability to perform Declaration Merging.
Essentially combining multiple interface definitions with the same name into one, for example:
interface Animal {
age: number
}
interface Animal {
color: string
}
const doge: Animal = {
age: 5,
color: 'white'
};
// No error so far! :)
const cato: Animal = {
age: 1,
color: 'black',
size: 'smol' // << Error: "size" does not exist in type "Animal"
};
So going back to my case, Now I had to see the express source code and understand how they allowed for declaration merging to happen across different modules. And thats exactly what I’ve done:
Found out where and how express have defined those extendable types (specifically interfaces)
Noted also that they were using declare global
and namespace Express
to allow more easily accessing this interface from a different module.
The implementation
Now all I had to do is apply the same techniques to my code.
Remember the “Core” module with “AvailableFoods”?
// some-core-module.ts
export interface AvailableFoods {
apple: number;
mango: number;
cake: number;
};
Note that I haven’t used a namespace like express did in their source code,
though I might add it later after reading more thoroughly about module mechanics and the declare global
in JS.
Now onto applying declaration merging between my Core module and the end-consumer implementation.
// Some end-consumer implementation
import { AvailableFoods } from '@another/module';
declare module '@another/module' {
interface AvailableFoods {
beef: number
}
}
const foodInFridge: AvailableFoods = {
apple: 2,
beef: 5, // No error here anymore!
};
Were able to define types inside some external module by using declare module 'some-module'
.
Then inside the declaration we wrote an interface which was performing declaration merging with my core code’s interface under the same name.
And this did the trick! Actually worked pretty well. Code readability was staying high, and the addition was easy to write and explain.
So to recap:
When writing some generic code, or especially an importable module with types that might need to be extended from the consumer code, consider using Declaration Merging.
It results in a more readable, cleaner code.
The alternative: You could use the generic type approach instead (as shown above), and in other cases it would be the better choice.