TypeScript — Tips & Tricks
More and less known ways to improve your TypeScript code
I’ve recently been a guest on the “Angular Academy Show” (watch it here) to demo some cools behaviours of TypeScript. Since people were interested in the source code, and I realised that it will be even more valuable with some comments, I’m putting it here as this article.
Tip 1. TypeScript & DOM
When you start using TypeScript you will notice it’s quite knowledgeable when it comes to working with in a browser environment. Let's say I wanted to find an <input>
element on the page:
const textEl = document.querySelector('inpot');console.log(textEl.value); // 🛑 Property 'value' does not exist on type 'Element'.
So TypeScript shows me that error, because… I have a typo in the querySelector
method and instead of "input" I'm looking for an "inpot".
How does it knows that? The answer lies in the lib.dom.d.ts
file which is a part of TypeScript library and, basically, describes everything (objects, functions, events) that occur in a browser. Part of this definition is the interface that is used within thequerySelector
method’s typing and maps specific string literals (like "div", "table" or "input") to corresponding HTML element types:
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"applet": HTMLAppletElement;
"area": HTMLAreaElement;
"article": HTMLElement;
/* ... */
"input": HTMLInputElement;
/* ... */
}
It’s not a perfect solution because it only work for basic element selectors, but still better that than nothing, right?
Another example of this ‘smart’ TypeScript behaviour is when you work with browser events:
textEl.addEventListener('click', e => {
console.log(e.clientX);
});
.clientX
in the example above is not a property available on any given event - it's only available on MouseEvent
specifically. And TypeScript figured the type of event out based on that "click"
literal that's the first argument in addEventListener
method.
Tip 2. Expect Generics
So if, instead of an element selector, you use anything else:
document.querySelector('input.action')
then the HTMLELementTagNameMap
won't be useful, and TypeScript will just return a fairly basic Element
type.
As with querySelector
it's often the case that a function can return various different structures and it's impossible for TypeScript to determine which one will that be. In that case you can pretty much expect, that said function is also a generic and you can provide that return type in a convenient generic syntax:
textEl = document.querySelector<HTMLInputElement>('input.action');console.log(textEl.value);// 👍 'value' is available because we've instructed TS
// about the type the 'querySelector' function works with.
If you work with Angular then another example could be the ElementRef
class:
Tip 3. “Did we really find it?”
That document.querySelector(...)
method doesn't actually always return us an object, does it? An element matching that selector might not be on the page - and instead of an object the function will return null
. So accessing that, say, .value
property might not be all that save... by default.
By default, the type checker considers null
and undefined
as assignable to any type. You can make this more safe and restrict this behaviour by adding strict null checks in tsconfig.json
:
{
"compilerOptions": {
"strictNullChecks": true
}
}
With that setting, TypeScript will now complain if you try to access the property on an object that is possibly null
, and you will have to “reassure” it about the object’s existence, e.g. by wrapping that part with if (textEl) {...}
condition.
Other than querySelector
, the other popular case for this is Array.find
method - the result is possibly undefined
.
You don't always find what you're looking for :-)
Tip 4. “I’m telling you, TS, it is there!”
As we’ve established, with strict null checks TypeScript will be much more sceptical about our values. On the other hand, sometimes you just know from the external means that the value will be set. In such exceptional cases you can use “post-fix expression operator”:
const textEl = document.querySelector('input');console.log(textEl!.value); // 👍 with "!" we assure TypeScript
// that 'textEl' is not null/undefined
Tip 5. When migrating to TS…
Often when you have a legacy codebase that you want to migrate to TypeScript, one of the bigger hustles is to make id adhere to your TSLint rules. What you can do is edit all these files by adding
// tslint:disable
in the first line of each, so that TSLint don’t actually check them. Then, only when a developer works on a legacy file, he will start by removing this one comment and fix all linting errors only in this file. That way we don’t do a revolution but rather an evolution — and the codebase gradually, but safely, will improve.
As for adding the actual types to that old JavaScript code, you actually can often do without. TypeScript’s type inference will take care of it and only if you had some nasty code, e.g. that assigns different type of values to the same variable, you can have a problem. If refactoring that is not a trivial issue you can (sigh..) resolve to using any
:
let mything = 2;mything = 'hi';
// 🛑 Type '"hi"' is not assignable to type 'number'mything = 'hi' as any;
// 👍 if you say "any", TypeScript says ¯\_(ツ)_/¯
But really really, really use it as a last resort. We don’t like any
in TypeScript.
Tip 6. More type restrictions
Sometimes TypeScript can’t infer the type. Most common case is a function parameter:
function fn(param) {
console.log(param);
}
Internally, it needs to assign some type to param
here, so it assigns any
. Since we want to limit any
-s to the absolute minimum it's usually recommended to restrict that behaviour with another tsconfig.json
setting:
{
"compilerOptions": {
"noImplicitAny": true
}
}
Unfortunately, we can’t put that kind of safety belt (that’d require an explicit typing) on a function return type. So if instead function fn(param): string {
I'll forget that type (function fn(param) {
), TypeScript will not keep an eye on what I return, or even if I return anything from that function. More precisely: it will infer the return value from whatever you’ve returned or not returned.
Thankfully that’s where TSLint helps. With the typedef
rule you can make return types required:
{
"rules": {
"typedef": [
true,
"call-signature"
]
}
}
Which I’d argue is a good idea.
Tip 7. Type Guards
When you have a value that can be of several types, you have to take that into account in your algorithm to differentiate one type from the other. The thing about TypeScript is that it understands this logic.
type BookId = number | string;function returnFormatterId(id: BookId) { return id.toUpperCase();
// 🛑 'toUpperCase' does not exist on type 'number'.}function returnFormatterId(id: BookId) { if (typeof id === 'string') {
// we've made sure it's a string:
return id.toUpperCase(); // so it's 👍
} // 👍 TS also understands that it
// has to be a number here:
return id.toFixed(2)
}
Tip 8. Once more about the generics
Let’s say we have this kind of quite general construct:
interface Bookmark {
id: string;
}class BookmarksService {
items: Bookmark[] = [];
}
You want to use it in different applications, e.g. for storing Books or Movies.
In such application you could do something like:
interface Movie {
id: string;
name: string;
}class SearchPageComponent {
movie: Movie;
constructor(private bs: BookmarksService) {} getFirstMovie() {
// 🛑 types are not assignable
this.movie = this.bs.items[0]; // 👍 so you have to manually assert type:
this.movie = this.bs.items[0] as Movie;
} getSecondMovie() {
this.movie = this.bs.items[1] as Movie;
}
}
This kind of type assertion might be needed several times in that class.
What we can do instead is to define the BookmarksService
class as a generic:
class BookmarksService<T> {
items: T[] = [];
}
Well, now it is too generic though… We want to assure that the types this class will work with will fulfil the Bookmark
interface (i.e. have the id: string
property). Here is the improved declaration:
class BookmarksService<T extends Bookmark> {
items: T[] = [];
}
Now in our SearchPageComponent
we only need to specify the type once:
class SearchPageComponent {
movie: Movie;
constructor(private bs: BookmarksService<Movie>) {} getFirstMovie() {
this.movie = this.bs.items[0]; // 👍
} getSecondMovie() {
this.movie = this.bs.items[1]; // 👍
}
}
There is one additional improvement for that generic class that might be useful — if you are using it in other places in that general capacity and don’t want to write BookmarksService<Bookmark>
is such cases.
You can provide a default type in the generic's definition:
class BookmarksService<T extends Bookmark = Bookmark> {
items: T[] = [];
}const bs = new BookmarksService();
// I don't have to provide the type for the generic now
// - in that case 'bs' will be of that default type 'Bookmark'
Tip 9. Type your route params
This one is specifically for the Angular folks. I don’t think I can describe this better than I did in original tweet — so here it is:
Tip 10. Create Interfaces from API response
If you have a big, nested response from the API, it’s really tedious to type the corresponding interface(s) by hand. It’s a task that should be handled by machines! 🤖
There are couple of options:
..are some of them, but frankly, their servers are often not available. I just usually start with them because of very memorable urls :-) For the best results and some additional options too, use https://app.quicktype.io/
It also provides a handy Visual Studio Code plugin:
Tip 11. For more tips…
I share them pretty often on Twitter, so if you are interested in such things, follow me there 🤓
And as a final word, a big shout-out to Angular Academy Show — thank’s for hosting me! There are couple of interesting interviews there, e.g. if you’re into GraphQL or PWA… or other subjects, definitely pay them a visit :)