In this article, we’ll dive into a set of tips and tricks that should come in handy in every Angular project and beyond when dealing with TypeScript.
In recent years, the need for static typing in JavaScript has increased rapidly. Growing front-end projects, more complex services, and elaborate command-line utilities have boosted the need for more defensive programming in the JavaScript world. Furthermore, the burden of compiling an application before actually running it hasn’t seen as a weakness, but rather as an opportunity. While two strong parties (TypeScript and Flow) have emerged, a lot of trends actually indicate that only one may prevail — TypeScript.
Besides the marketing claims and commonly known properties, TypeScript has an amazing community with very active contributors. It also has one of the best teams in terms of language design behind it. Led by Anders Hejlsberg, the team has managed to fully transform the landscape of large-scale JavaScript projects to be nearly an exclusively TypeScript-powered business. With very successful projects such as VSTS or Visual Studio Code, Microsoft themselves is a strong believer in this technology.
But it’s not only the features of TypeScript that make the language appealing, but also the possibilities and frameworks that TypeScript is powering. Google’s decision to fully embrace TypeScript as their language of choice for Angular 2+ has proven to be a win-win. Not only did TypeScript gain more attention, but also Angular itself. Using static typing, the compiler can already give us informative warnings and useful explanations of why our code will not work.
TypeScript Tip 1: Supply Your Own Module Definitions
TypeScript is a superset of JavaScript. As such, every existing npm package can be utilized. While the TypeScript eco-system is huge, not all libraries are yet delivered with appropriate typings. Even worse, for some (smaller) packages not even separate declarations (in the form of @types/{package}
) exist. At this point, we have two options:
- bring in legacy code using TypeScript tip 7
- define the API of the module ourselves.
The latter is definitely preferred. Not only do we have to look at the documentation of the module anyway, but typing it out will prevent simple mistakes during development. Furthermore, if we’re really satisfied with the typings that we just created, we can always submit them to @types
for including them on npm. As such, this also rewards us with respect and gratefulness from the community. Nice!
What’s the easiest way to supply our own module definitions? Just create a module.d.ts
in the source directory (or it could also be named like the package — for example, unknown-module.d.ts
for an npm package unknown-module
).
Let’s supply a sample definition for this module:
declare module 'unknown-module' {
const unknownModule: any;
export = unknownModule;
}
Obviously, this is just the first step, as we shouldn’t use any
at all. (There are many reasons for this. TypeScript tip 5 shows how to avoid it.) However, it’s sufficient to teach TypeScript about the module and prevent compilation errors such as “unknown module ‘unknown-module’”. The export
notation here is meant for the classic module.exports = ...
kind of packages.
Here’s the potential consumption in TypeScript of such a module:
import * as unknownModule from 'unknown-module';
As already mentioned, the whole module definition is now placed in the type declaration of the exported constant. If the exported content is a function, the declaration could look like this:
declare module 'unknown-module' {
interface UnknownModuleFunction {
(): void;
}
const unknownModule: UnknownModuleFunction;
export = unknownModule;
}
Of course, it’s also possible to use packages that export functionality using the ES6 module syntax:
declare module 'unknown-module' {
interface UnknownModuleFunction {
(): void;
}
const unknownModule: UnknownModuleFunction;
export const constantA: number;
export const constantB: string;
export default unknownModule;
}
TypeScript Tip 2: Enum vs Const Enum
TypeScript introduced the concept of enumerations to JavaScript, which did represent a collection of constants. The difference between
const Foo = {
A: 1,
B: 2,
};
and
enum Foo {
A = 1,
B = 2,
}
is not only of syntactical nature in TypeScript. While both will be compiled to an object (i.e., the first one will just stay as is, while the latter will be transformed by TypeScript), the TypeScript enum
is protected and contains only constant members. As such, it would not be possible to define its values during runtime. Also, changes of these values will not be permitted by the TypeScript compiler.
This is also reflected in the signature. The latter has a constant signature, which is similar to
interface EnumFoo {
A: 1;
B: 2;
}
while the object is generalized:
interface ConstFoo {
A: number;
B: number;
}
Thus we wouldn’t see the values of these “constants” in our IDE. What does const enum
now give us? First, let’s look at the syntax:
const enum Foo {
A = 1,
B = 2,
}
This is actually the same — but note, there’s a const
in front. This little keyword makes a giant difference. Why? Because under these circumstances, TypeScript won’t compile anything. So we have the following cascade:
- objects are untouched, but generate an implicit generalized shape declaration (interface)
enum
will generate some boilerplate object-initializer along with a specialized shape declarationconst enum
doesn’t generate anything beside a specialized shape declaration.
Now how is the latter then used in the code? By simple replacements. Consider this code:
enum Foo {
A = 1,
B = 2
}
const enum Bar {
A = 1,
B = 2
}
console.log(Bar.A, Foo.B);
Here we end up in JavaScript with the following outcome:
var Foo;
(function (Foo) {
Foo[Foo["A"] = 1] = "A";
Foo[Foo["B"] = 2] = "B";
})(Foo || (Foo = {}));
console.log(1 /* A */, Foo.B);
Note that 5 lines alone have been generated for enum Foo
, while enum Bar
only resulted in a simple replacement (constant injection). Thus const enum
is a compile-time only feature, while the original enum
is a runtime + compile-time feature. Most projects will be well suited for const enum
, but there may be cases where enum
is preferred.
TypeScript Tip 3: Type Expressions
Most of the time, we’re satisfied with using interface
for defining new shapes of objects. However, there are cases when a simple interface isn’t sufficient any more. Consider the following example. We start with a simple interface:
interface StatusResponse {
issues: Array<string>;
status: 'healthy' | 'unhealthy';
}
The notation in 'healthy' | 'unhealthy'
means either a constant string being healthy
or another constant string equal to unhealthy
. Alright, this is a sound interface definition. However, now we also have a method in our code, that wants to mutate an object of type StatusResponse
:
function setHealthStatus(state: 'healthy' | 'unhealthy') {
// ...
}
So far, so good, but changing this now to 'healthy' | 'unhealthy' | 'unknown'
results in two changes already (one in the interface definition and one in the definition of argument type in the function). Not cool. Actually, the expressions we looked at until now are already type expressions, we just did not “store” them — that is, give them a name (sometimes called alias). Let’s do that:
type StatusResponseStatus = 'healthy' | 'unhealthy';
While const
, var
, and let
create objects at runtime from JS expressions, type
creates a type declaration at compile-time from TS expressions (so-called type expressions). These type declarations can then be used:
interface StatusResponse {
issues: Array<string>;
status: StatusResponseStatus;
}
With such aliases in our tool belt, we can easily refactor the type system at will. Using TypeScript’s great type inference just propagates the changes accordingly.
TypeScript Tip 4: Use Discriminators
One of the uses of type expressions is the formerly introduced union of several (simple) type expressions — that is, type names or constants. Of course, the union is not restricted to simple type expressions, but for readability we should not come up with structures such as this:
type MyUnion = {
a: boolean,
b: number,
} | {
c: number,
d: {
sub: string,
}
} | {
(): void;
};
Instead, we want a simple and straightforward expression, such as this:
type MyUnion = TypeA | TypeB | TypeC;
Such a union can be used as a so-called discriminated union if all types expose at least one member with the same name, but a different (constant) value. Let’s suppose we have three types, such as these:
interface Line {
points: 2;
// other members, e.g., from, to, ...
}
interface Triangle {
points: 3;
// other members, e.g., center, width, height
}
interface Rectangle {
points: 4;
// other members, e.g., top, right, bottom, left
}
A discriminated union between these types could be this:
type Shape = Line | Triangle | Rectangle;
This new type can now be used in functions, where we can access specific members using some validation on the discriminator, which would be the points
property. For example:
function calcArea(shape: Shape) {
switch (shape.points) {
case 2:
// ... incl. return
case 3:
// ... incl. return
case 4:
// ... incl. return
default:
return Math.NaN;
}
}
Naturally, switch
statements come in quite handy for this task, but other means of validation can also be used.
Discriminated unions come in handy in all kinds of scenarios — for example, when traversing an AST-like structure of when dealing with JSON files that have a similar branching mechanism in their schema.
TypeScript Tip 5: Avoid Any Unless It Really Is Any
We’ve all been there: we know exactly what code to write, but we’re unable to satisfy the TypeScript compiler to accept our data model for the code. Well, luckily for us we can always fall back to any
for saving the day. But we shouldn’t. any
should only be used for types that can in fact be any. (For example, it’s on purpose that JSON.parse
returns any
, as the outcome could be anything depending on the string we’re parsing.)
For instance, in one of our data stores we defined explicitly that a certain field custom
will hold data of type any
. We don’t know what will be set there, but the consumer is free to choose the data (and thus the data type). We neither wanted nor could prevent this from happening, so the type any
was for real.
However, in most scenarios (that is, in all scenarios that are exclusively covered by our code) any
is usually one or multiple types. We only need to find out what type exactly we expect and how to construct such a type to give TypeScript all the necessary information.
Using some of the previous tips — for example, TypeScript tip 4 and TypeScript tip 3 — we can already solve some of the biggest problems:
function squareValue(x: any) {
return Math.pow(x * 1, 2);
}
We’d much rather constrain the input as much as possible:
function squareValue(x: string | number) {
return Math.pow(+x, 2);
}
Now the interesting part is that the former expression x * 1
is allowed with any
, but disallowed in general. However, the +x
gives us the forced cast to a number
as wanted. To check if our cast works with the given types, we need to be specific. The question “what types can enter here?” is a legit one that we need to answer before TypeScript can supply us with useful information.
TypeScript Tip 6: Use Generics Efficiently
TypeScript means static typing, but static typing doesn’t mean explicit typing. TypeScript has powerful type inference, which has to be used and fully understood before one can be really productive in TypeScript. Personally, I think I’ve become far more productive in TypeScript than plain JavaScript, as I don’t spend much time on my typings, yet everything seems to be in place and almost all trivial errors are already detected by TypeScript. One of the drivers behind this productivity boost is generics. Generics gives us the ability to bring in types as variables.
Let’s consider the following case of a classic JS helper function:
function getOrUpdateFromCache(key, cb) {
const value = getFromCache(key);
if (value === undefined) {
const newValue = cb();
setInCache(key, newValue);
return newValue;
}
return value;
}
Translating this directly to TypeScript leaves us behind with two any
s: one is the data retrieved from the callback, and one from the function itself. However, this doesn’t need to look like that, since we obviously know the type (we pass in cb
):
function getOrUpdateFromCache<T>(key: string, cb: () => T) {
const value: T = getFromCache(key);
if (value === undefined) {
const newValue = cb();
setInCache(key, newValue);
return newValue;
}
return value;
}
The only troublesome position in the code above is the explicit type assignment to the result of calling the getFromCache
function. Here we must trust our code for the moment to consistently only use the same types for the same keys. In TypeScript tip 10 we learn how to improve this situation.
Most of the time the use of generics is just to “pass through” a type — that is, to teach TypeScript about the relation between certain argument types (in the former case the type of the result is connected to the return type of the callback). Teaching TypeScript about such relations can also be subject for further constraints, which are then put in place by TypeScript.
While generics is easy to use together with interfaces, types, classes, and standard functions, they may not seem so approachable with arrow functions. These functions are anonymous by definition (they need to be assigned to a variable to be accessed via a name).
As a rule of thumb, we can follow this approach: just think of a normal, but anonymous function declaration. Here only the name is gone. As such the <T>
is naturally just placed before the parentheses. We end up with:
const getOrUpdateFromCache = <T>(key: string, cb: () => T) => /* ...*/;
However, once we would introduce this in a TSX file (for whatever reason), we would end up with an error ERROR : unclosed T
tag. It’s the same problem that appears with casts (solved there by using the as
operator). Now our workaround is to tell TypeScript explicitly that the syntax was intended for generics use:
const getOrUpdateFromCache = <T extends {}>(key: string, cb: () => T) => /* ...*/;
TypeScript Tip 7: Bring in Legacy Code
The key for migrating existing code to TypeScript has been a set of well-adjusted TypeScript configuration parameters — for example, to allow implicit any
and to disable strict mode. The problem with this approach is that transformed code goes from a legacy state into a freeze state, which also impacts the new code that’s being written (since we disabled some of the most useful compiler options).
A better alternative is to just use allowJs
in the tsconfig.json
file, next to the usual (quite strong) parameters:
{
"compilerOptions": {
"allowJs": true,
// ...
}
}
Now instead of already renaming existing files from .js
to .ts
, we keep existing files as long as possible. We will only rename if we seriously can tackle the content in such a way that the code is fully transformed from JavaScript to a TypeScript variant that satisfies our settings.
TypeScript Tip 8: Create Functions with Properties
We already know that using interfaces to declare the shape of a function is a sound way. Furthermore, this approach allows us to attach some properties to the given function type. Let’s first see how this may look in practice:
interface PluginLoader {
(): void;
version: string;
}
Defining this is straightforward, but unfortunately, working with it isn’t. Let’s try to use this interface as intended by creating an object that fulfills the interface:
const pl: PluginLoader = () => {};
pl.version = '1.0.0';
Ouch: we can’t get past the declaration. TypeScript (correctly) complains, that the version
property are missing. Okay, so how about the following workaround:
interface PluginLoaderLight {
(): void;
version?: string;
}
const pl: PluginLoaderLight = () => {};
pl.version = '1.0.0';
Perfect. This works, but it has one major drawback: even though we know that past the pl.version
assignment the version
property will always exist at pl
, TypeScript doesn’t know that. So from its point of view, any access to version
could be wrong and needs to be checked against undefined
first. In other words, in the current solution the interface we use for producing an object of this type has to be different from the interface used for consuming. This isn’t ideal.
Fortunately, there is a way around this problem. Let’s return to our original PluginLoader
interface. Let’s try it with a cast that states to TypeScript “Trust me, I know what I’m doing”.
const pl = <PluginLoader>(() => {});
pl.version = '1.0.0';
The purpose of this is to tell TypeScript, “See this function, I know it will be of this given shape (PluginLoader
)”. TypeScript still checks if this can be still fulfilled. Since there are no clashing definitions available, it will accept this cast. Casts should be our last line of defense. I don’t consider any
a possible line of defense: either the type is any
for real (can always be — we just accept anything, totally fine), or it should not be used and has to be replaced by something specific (see TypeScript tip 5).
While the way of casting may solve problems such as the described one, it may not be feasible in some non-Angular environment (for example, React components). Here, we need to choose the alternative variant of casting, namely the as
operator:
const pl = (() => {}) as PluginLoader;
pl.version = '1.0.0';
Personally, I would always go for as
-driven casts. Not only do they always work, they’re also quite readable even for someone who doesn’t have a TypeScript background. For me, consistency and readability are two principles that should always be at the core of every codebase. They can be broken, but there have to be good reasons for doing so.
TypeScript Tip 9: The keyof Operator
TypeScript is actually quite good at — well — handling types. As such, it gives us some weapons that can be used to boilerplate some code for actually generating the content of an interface. Likewise, it also offers us options for iterating through the content of an interface.
Consider the following interface:
interface AbstractControllerMap {
user: UserControllerBase;
data: DataControllerBase;
settings: SettingsControllerBase;
//...
}
Potentially, in our code we have an object with a similar structure. The keys of this object are magic: its strings are used in many iterations and thus on many occasions. Quite likely we use these keys as arguments somewhere.
Obviously, we could just state that a function could look like this:
function actOnAbstractController(controllerName: string) {
// ...
}
The downside is that we definitely have more knowledge, which we do not share with TypeScript. A better version would therefore be this:
function actOnAbstractController(controllerName: 'user' | 'data' | 'settings') {
// ...
}
However, as already noted in TypeScript tip 3, we want to be resilient against refactorings. This is not resilient. If we add another key (that is, map another controller in our example above), we’ll need to edit the code in multiple locations.
A nice way out is provided by the keyof
operator, which works against any type. For instance, aliasing the keys of the AbstractControllerMap
above looks as follows:
type ControllerNames = keyof AbstractControllerMap;
Now we can change our function to truly become resilient against refactorings on the original map.
function actOnAbstractController(controllerName: ControllerNames) {
// ...
}
The cool thing about this is that keyof
will actually respect interface merging. No matter where we place the keyof
, it will always work against the “final” version of the type it’s applied to. This is also very useful when thinking about factory methods and efficient interface design for them.
TypeScript Tip 10: Efficient Callback Definitions
A problem that appears more often than anticipated is the typing of event handlers. Let’s look at the following interface for a second:
interface MyEventEmitter {
on(eventName: string, cb: (e: any) => void): void;
off(eventName: string, cb: (e: any) => void): void;
emit(eventName: string, event: any): void;
}
Looking back at all the previous tricks, we know that this design is neither ideal nor acceptable. So what can we do about it? Let’s start with a simple approximation to the problem. A first step is certainly to define all possible event names. We could use type expressions as introduced in TypeScript tip 3, but even better would be a mapping to the event type declarations like in the previous tip.
So we start with our map and apply TypeScript tip 9 to obtain the following:
interface AllEvents {
click: any;
hover: any;
// ...
}
type AllEventNames = keyof AllEvents;
This has already some effect. The previous interface definition now becomes:
interface MyEventEmitter {
on(eventName: AllEventNames, cb: (e: any) => void): void;
off(eventName: AllEventNames, cb: (e: any) => void): void;
emit(eventName: AllEventNames, event: any): void;
}
A little bit better, but we still have any
on all interesting positions. Now TypeScript tip 6 can be applied to make TypeScript a little bit more knowledgeable about the entered eventName
:
interface MyEventEmitter {
on<T extends AllEventNames>(eventName: T, cb: (e: any) => void): void;
off<T extends AllEventNames>(eventName: T, cb: (e: any) => void): void;
emit<T extends AllEventNames>(eventName: T, event: any): void;
}
This is good, but not sufficient. TypeScript now knows about the exact type of eventName
when we enter it, but we’re unable to use the information stored in T
for anything. Except, we can use it with another powerful type expressions: index operators applied to interfaces.
interface MyEventEmitter {
on<T extends AllEventNames>(eventName: T, cb: (e: AllEvents[T]) => void): void;
off<T extends AllEventNames>(eventName: T, cb: (e: AllEvents[T]) => void): void;
emit<T extends AllEventNames>(eventName: T, event: AllEvents[T]): void;
}
This seems to be powerful stuff, except that our existing declarations are all set to any
. So let’s change this.
interface ClickEvent {
leftButton: boolean;
rightButton: boolean;
}
interface AllEvents {
click: ClickEvent;
// ...
}
The real powerful part is now that interface merging still works. That is, we can extend our event definitions out of place by using the same interface name again:
interface AllEvents {
custom: {
field: string;
};
}
This makes type expressions even more powerful, as the extensibility is integrated in a wonderful and elegant way.
Further Reading
- (Original, 2012) Introducing TypeScript — JavaScript on Steroids
- Introduction to TypeScript
- TypeScript GitBook on Discriminated Unions
- The Official TypeScript Blog
- Getting Started with Angular 2 using TypeScript
Conclusion
Hopefully one or more of these TypeScript tips were new to you or at least something you wanted to see in a closer write up. The list is far from complete, but should give you a good starting point to avoid some problems and increase productivity.
What tricks make your code shine? Where do you feel most comfortable in? Let us know in the comments!