TypeScript with AngularJS
What? AngularJS In 2024?
Well, yes. Unfortunately, some of us are still stuck with software that just won't die. Migrating a large codebase to a new framework is a daunting task, and your company might not be willing to spend the time and money on it.
But TypeScript is a different story. It's a superset of JavaScript, and it's backward compatible. Adding it to a project of any size might be as easy as renaming the file extension from .js
to .ts
. Well, you don't even have to do that, nowadays.
With AngularJS, though, there is a one big obstacle that stands in the way. And that obstacle is the dependency injection.
What is the problem?
You see, when you import code from files, TypeScript can track and infer the types of those imports. But when you use the AngularJS dependency injection, the type information is lost.
Imagine, you have a service called Counter
:
angular.module('myApp').service('Counter', () => {
const count = 0;
const Counter = {
increment: function() {
count++;
},
decrement: function() {
count--;
},
getCount: function() {
return count;
}
}
return Counter;
});
Now in some other file you want to reuse this service:
angular.module('myApp').controller('MyController', ['Counter', (Counter) => {
// wrong method...
Counter.incrementByOne();
// ...but TypeScript doesn't know that
// the type of Counter is any
})];
And TypeScript says: sure, go ahead. Use this method that doesn't exist, I don't really care.
So how can we fix it?
There's a simple and pragmatic way. But first, let's try the obvious way.
Obvious way
One thing we can do is declare the interface first, and then export it.
export interface ICounter {
increment: () => void;
decrement: () => void;
getCount: () => number;
}
angular.module('myApp').service('Counter', () => {
const count = 0;
const Counter: ICounter = {
increment: function() {
count++;
},
decrement: function() {
count--;
},
getCount: function() {
return count;
}
}
return Counter;
});
Then, in the other file, we can import the interface and use it.
import ICounter from './Counter.ts'
angular.module('myApp').controller('MyController', ['Counter', (Counter: ICounter) => {
// wrong method
Counter.incrementByOne();
});
And this works. But oooff. For each of the hundred services out there we will now have to maintain the interface, and make sure it stays in sync with the actual implementation. That's never gonna happen for a legacy project that can't find resources to migrate to a new framework.
Better way
We don't really care for polymorphism here, right? Most of the time our services are just singleton-kinda objects, and their APIs change often.
Let the TypeScript do the work. Let's define the interface "dynamically" based on the actual implementation.
angular.module('myApp').service(CounterFactory);
function CounterFactory() {
const count = 0;
return {
increment: function() {
count++;
},
decrement: function() {
count--;
},
getCount: function() {
return count;
}
}
};
// the magic line
export type ICounter = ReturnType<typeof CounterFactory>;
So, the typeof does the magic of transforming something from the value world to the type world. And ReturnType extracts the return type of a given function.
And that's it. Now we have a type to reuse in other files, and benefit from the type checking. And, the required change is minimal.
Summary
In summary, by using TypeScript’s ReturnType
and typeof
, we can bring type safety to AngularJS services without adding extra code to maintain. It’s a small change with big benefits for legacy codebases.
P.S.: A message of support to all my dear fellow AngularJS-ers. I hope TypeScript will make your gruesome days a bit brighter. It certainly helped me a lot to maintain this mess. Stay strong! 🤗