Master TypeScript Quality with This Essential Checklist

Aris Pattakos

Aris Pattakos

· 9 min read
8-bit pixel art showing a laptop and notebook, inspired by Stardew Valley, generated by MidJourney

When I started using TypeScript it felt really simple. But in a sense, it also felt like I was just writing JavaScript with some extra effort on top to maintain types. I couldn’t see all the value it had to offer, at least not immediately.

It took me some time to start realising the benefits of using TypeScript, and the things that I need to be careful with. After a lot of trial & error, I have learned how to make good use of TypeScript and it’s one of the most important tools that I have available.

Good TypeScript protects you from bugs and makes collaboration with others much easier. Bad TypeScript, on the other hand, can give you false confidence while allowing numerous bugs through.

Whenever I’m looking at TypeScript code, I ask myself the following questions to ensure that I’m making the best possible use of the language.

Am I silencing type checks?

TypeScript is designed as a superset of JavaScript. This means that a JavaScript program, with no typing rules at all, is still considered valid TypeScript. This design decision, means that you can easily have code that’s not type-checked within your TypeScript code.

This flexibility is useful when migrating from JavaScript to TypeScript. But when your project is built in TypeScript, you shouldn’t be silencing type checks because it defeats the purpose of using it.

You should use strict mode when building a TypeScript project, but even then there are ways to silence type checks:

  • Using any
  • Using // @ts-ignore
  • Making unsafe type assertions with x as unknown as MyType

Using any or // @ts-ignore silences type checks, and should rarely ever be used in production code. Type assertions have their uses, but you should make sure that using them is not just a way to silence type checking.

Does the Type represent a valid state?

Types should always represent something that can really happen inside of your code. A common symptom of this problems is when your types allow certain combinations of values that are theoretically impossible.

Let’s say we have 2 kinds of products in our app: shoes & t-shirts. This is a Type that you might often see in such cases, but there’s a problem with it. Can you spot it?

type Product = {
  kind: "tshirt" | "shoes";
  price: number;
  shoeSize?: number;
  tShirtSize?: 'S' | 'M' | 'L';
};

As far as TypeScript is concerned, the product can have both shoeSize and tShirtSize available at the same time. Or the kind might be tshirt and tShirtSize might be missing. We know that these cases shouldn’t be possible based on our application logic, so how do we let TypeScript know that as well?

We’ll use Discriminated Unions to handle this case where one property (kind) gives us information about what other properties will be available.

type BaseProduct = {
  id: number;
  price: number;
}

type ShoesProduct = BaseProduct & {
  kind: 'shoes';
  shoeSize: number;
}

type TshirtProduct = BaseProduct & {
  kind: 'tshirt';
  tShirtSize: 'S' | 'M' | 'L';
}

type Product = ShoesProduct | TshirtProduct;

const product = getProductById(1);
if (product.kind === 'shoes') {
  // TypeScript knows that `product` is a `ShoesProduct` here
  // so we can safely access the `shoeSize` property.
  console.log(product.shoeSize);
}

In the example above you can see how this no longer supports impossible sets of values, and the types closely match the application logic.

A lot of times you also have properties that are defined together and undefined together. Let’s say that you have a type called Photo in your codebase which might have a set of coordinates (lat & long). In this pair of values, you can either have both or none. Having just one of them defined is an impossible state.

type Photo = {
  url: string;
  lat?: number;
  long?: number;
};

How might you fix the example above? Given that we know that lat and long follow each other, we can group them under a new property.

type Photo = {
  url: string;
  coordinates?: {
    lat: number;
    long: number;
  }
};

This is much better now, as coordinates either has both values or none of them. This means that we turned an impossible state into one that matches our actual logic.

Is there redundant code?

DRY in software engineering stands for Don’t Repeat Yourself. As in any programming language or paradigm, TypeScript code can end up being overly wordy and contain redundant code.

A common reason for repeated code in TypeScript is not relying on inference. Inference is TypeScript’s ability to understand (infer) a lot of types automatically. Here are some examples of how you can use inference to avoid redundant types.

// Assuming you have a method that returns a User type
interface User {
  id: string;
  name: string;
}

class UserService {
  getUser: (id: string) => User;
}

const userService = new UserService();
const user: User = userService.getUser('123');

In the example above, we’ve explicitly typed the user variable, but that was completely unnecessary. We can make our code easier to read by removing the explicit type.

const user = userService.getUser('123');

Since getUser returns a User object, TypeScript knows the type that the user constant will have.

Let’s look at some more examples.

// ❌ The explicit return type is unnecessary
const isOddNumber = (number: number): boolean => number % 2 === 1;

// ✅ Since we're returning a condition, TypeScript
// already knows it's a boolean
const isOddNumber = (number: number) => number % 2 === 1;

// ❌ TypeScript already knows it's a string based on the definition
let name: string = 'Full Name';

// ✅ We can make our code tidier like this
let name = 'Full Name';

Explicit types do have their uses. Sometimes you want code that’s short and easy to read, and other times you want to confirm that you’re returning the right thing. You will need to use your judgement as to which approach is suitable each time.

Is the Type as narrow as it can be?

Types are sets of possible values. When we define something as string it can be any string you can imagine, but it can’t be a number. The type string though is quite wide, and there may be cases where the possible set of values that we expect is much more narrow.

The possible status for an order, might be one of the values in this code example below.

// ✅ Using unions to define narrower
// types can be very helpful
type OrderStatus = 'Pending' | 'Processing' | 'Completed' | 'Cancelled';

Using a union or an enum has a lot of upside, compared to defining status as a string:

  • The union or enum acts as documentation for other engineers to know the set of possible values.
  • TypeScript will alert you when there’s a typo, or when these values are compared with values that would never be equal (e.g. orderStatus === 'Pnding' could never be true because it’s not in the set)
  • Your editor will provide improved auto-complete suggestions.

Am I future-proofing my code?

One of the things I personally love about TypeScrips is the toolset that it provides for future-proofing your code. Let’s look at a JavaScript example to illustrate code that isn’t future-proofed.

const messageForRole = {
  admin: "Hello Admin",
  user: "Welcome back!",
};

const user = getUserById(1);
console.log(messageForRole[user.role]);

What would happen if we added a new user role, say guest, but we forgot to change messageForRole? In this case we would obviously get an undefined value which can introduce bugs or cause runtime errors.

With TypeScript, we can create a type for roles and we can guard against problems like the one above. By using Record<UserRole, string>, we tell JavaScript that messageByRole must cover all roles to be a valid object. The error message protects from deploying faulty code to production and allows us to solve the issue easily.

type UserRole = 'admin' | 'user' | 'guest';
const messageByRole: Record<UserRole, string> = {
  admin: "Hello admin",
  user: "Welcome back"
};
// Property 'guest' is missing in type '{ admin: string; user: string; }'
// but required in type 'Record<UserRole, string>'.

Other issues of this kind are harder to spot because they don’t lead to language errors, but logical errors. For example, if you expect an action to happen based on a set of conditions, JavaScript doesn't have a good way of telling you to add new actions whenever new conditions are introduced.

const sendMessageToUser = (user, message) => {
  if (user.communicationMethod === 'email') {
    console.log(`Emailing ${user.name}: ${message}`)
  }
  if (user.communicationMethod === 'sms') {
    console.log(`Texting ${user.name}: ${message}`)
  }
  // If 'telegram' was added, this function would just do nothing
}

In this last example, if we added a new communicationMethod and didn’t update sendMessageToUser then the function would just do nothing! And that could be hard to spot because we wouldn’t see any errors immediately.

To account for the scenario above, which can occur quite often in real-life code, we can use exhaustive checks.

type User = {
  name: string;
  communicationMethod: "email" | "sms" | "telegram";
};

const assertNever = (value: never) => {
  throw new Error(`Unexpected value: ${value}`);
};

const sendMessageToUser = (user: User, message: string) => {
  switch (user.communicationMethod) {
    case "email":
      // sendEmail(user.name, message);
      break;
    case "sms":
      // sendSms(user.name, message);
      break;
    default:
      assertNever(user.communicationMethod);
    // Argument of type 'string' is not assignable to
    // parameter of type 'never'.
  }
};
Image

TypeScript is able to narrow down the possible values. If case "email" is not true, then TypeScript narrows down the type to “sms” or “telegram”. And then, if “sms” is also not true, then the only remaining option is “telegram”. Since we haven’t covered that case, we end up calling assertNever which expects a never type, with a string (“telegram”) which obviously TypeScript is not happy about.

This acts as useful reminder that forces you to check something in the future whenever certain underlying types change. You can use this approach to force yourself to think whenever a change happens.

Conclusion

TypeScript has a lot of fancy operators that can be useful to learn, but not all of them add a ton of value to your codebase. The questions and advice that I listed above are the ones that I believe are the most important to keep in mind when you’re think about the question “Is my TypeScript code good?”.

All of the examples here are simplistic, but they do illustrate how TypeScript can help you in various situations and how you can make the best possible use of it. I encourage you to try them in your personal projects or your work, and I’d love to hear your comments.

Did you find this article insightful? Then you might also enjoy reading about how you can Make TypeScript your ally or see more code examples.

Aris Pattakos

About Aris Pattakos

Lead Software Engineer at Flash Pack.

Previously, founder at GuestFlip.

Copyright ©2024 Bestpractices.tech. All rights reserved.