10 Essential TypeScript Tips and Tricks for Angular Devs

Share this article

10 Essential TypeScript Tips

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:

  1. bring in legacy code using TypeScript tip 7
  2. 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 declaration
  • const 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 anys: 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

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!

Frequently Asked Questions (FAQs) about TypeScript Tips and Tricks

What are some of the best practices for using TypeScript with Angular?

TypeScript and Angular are often used together to build robust, scalable applications. Some best practices include using TypeScript’s static typing to catch errors early, leveraging Angular’s dependency injection system, and using modules to organize your code. Additionally, you should always follow the principle of least privilege, meaning you should only give each part of your code the permissions it needs to function.

How can I improve my TypeScript coding skills?

Improving your TypeScript coding skills involves a combination of learning and practice. Start by understanding the basics of TypeScript, such as its syntax and features. Then, move on to more advanced topics like generics, decorators, and async/await. Practice by building small projects or contributing to open-source projects. Additionally, reading and understanding other people’s code can also be a great way to learn.

What are some common mistakes to avoid when using TypeScript?

Some common mistakes to avoid when using TypeScript include not using TypeScript’s static typing feature, not leveraging TypeScript’s powerful type inference, and not using TypeScript’s advanced features like generics and decorators. Additionally, avoid using any as a type unless absolutely necessary, as it can lead to runtime errors.

How can I use TypeScript to write cleaner code?

TypeScript offers several features that can help you write cleaner, more maintainable code. For example, you can use interfaces to define the shape of your data, use classes to encapsulate related functionality, and use modules to organize your code. Additionally, TypeScript’s static typing can help catch errors early, leading to cleaner, more reliable code.

What are some advanced TypeScript features I should know about?

Some advanced TypeScript features you should know about include generics, decorators, and async/await. Generics allow you to write reusable, type-safe code. Decorators provide a way to add annotations and a meta-programming syntax for class declarations and members. Async/await makes it easier to write asynchronous code.

How can I use TypeScript with other JavaScript frameworks?

TypeScript can be used with any JavaScript framework, including React, Vue, and Node.js. To use TypeScript with these frameworks, you’ll need to configure your build system to compile TypeScript to JavaScript. Additionally, you’ll need to use TypeScript’s type definitions for the framework you’re using.

How can I debug TypeScript code?

Debugging TypeScript code is similar to debugging JavaScript code. You can use console.log statements, or you can use a debugger like Chrome DevTools or Visual Studio Code’s built-in debugger. Additionally, TypeScript’s static typing can help catch errors early, before they cause problems at runtime.

How can I optimize my TypeScript code for performance?

Optimizing TypeScript code for performance involves a combination of general JavaScript performance techniques and TypeScript-specific techniques. For example, you can use TypeScript’s static typing to catch errors early, which can prevent costly runtime errors. Additionally, you can use TypeScript’s advanced features like generics and decorators to write more efficient, reusable code.

How can I handle errors in TypeScript?

Handling errors in TypeScript is similar to handling errors in JavaScript. You can use try/catch blocks to catch and handle errors. Additionally, TypeScript’s static typing can help catch errors early, before they cause problems at runtime.

How can I test my TypeScript code?

Testing TypeScript code is similar to testing JavaScript code. You can use testing frameworks like Jest or Mocha, and you can use assertion libraries like Chai. Additionally, TypeScript’s static typing can help catch errors early, making your tests more reliable.

Florian RapplFlorian Rappl
View Author

Florian Rappl is an independent IT consultant working in the areas of client / server programming, High Performance Computing and web development. He is an expert in C/C++, C# and JavaScript. Florian regularly gives talks at conferences or user groups. You can find his blog at florian-rappl.de.

angularAngular Resourcesangular-hubTypeScript
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week
Loading form