TypeScript The Right Way

TypeScript has quickly become the go-to language for modern web development in recent years. It successfully addresses some of JavaScript’s bad parts, but it doe s it in a non intrusive manner which allows developers to choose how much they want to rely on TS in their projects. This leads to some pretty big differences in TypeScript codebases. For instance, both these snippets are valid TypeScript, but they could easily pass as different programming languages.

function check1(entity: Map<string, string>, fields: string[]) {
  for (const field of fields) {
    if (!entity[field]) {
      return false;
    }
  }
  return true;
}
type Mandatory<T> = {
  [K in keyof T]-?: T[K];
};

function check<T>(entity: Mandatory<T>, fields: (keyof T)[]): boolean {
  for (const field of fields) {
    if (!entity[field]) {
      return false;
    }
  }
  return true;
}

Due to this permissive nature things can easily go south when working with TypeScript, so let’s look at some of the most important TS best practices, and we’ll break down some of the most common mistakes even experienced developers often make.

You’ll probably be forced to use TypeScript if you are building for the Web, so having a good understanding of the core principles behind this tech is pretty important.

Let’s start by making sure we are all on the same page and clarify what TypeScript actually is.

What is TypeScript

Your favorite tech streamer will tell you that TypeScript is simply a linter, which, in all fairness, is a good enough definition. However, there is a bit more to the story.

JavaScript is a dynamically typed language. In theory this makes it flexible and easy to use, and its developers enjoy better productivity. In practice however, all these promises fail when the browser interprets your code at runtime and your logic breaks due to a dumb little type mismatch.

let name = "Awesome";
name = 100;
name.toUpperCase();

// Uncaught TypeError: name.toUpperCase is not a function

This is where the TypeScript static type checking comes into play. It extends the JavaScript language by adding a type system used to define and enforce the expected shapes and behaviors of data throughout your code.

This means that you can define the types of variables, function parameters, return values, and object structures upfront, allowing the TS compiler to catch type-related errors before your code even runs.

let message: string = "Hello, TypeScript!";

function add(a: number, b: number): number {
  return a + b;
}

interface Person {
  name: string;
  age: number;
}

let person: Person = {
  name: "Awesome",
  age: 30,
};

Once TS is compiled, usually by some sort of Node JS process, the resulting code is the JavaScript you can load and run in the browser. We can see this in practice, with just a couple of easy steps.

Let’s say you have a basic function that adds up two numbers in a main.js file. In pure JavaScript there isn’t anything stopping you from passing strings or any other type of object as a parameter to this function.

// main.js

function add(a, b) {
  return a + b;
}

console.log(add(1, 2));
console.log(add("Awesome", { value: 20 }));

To avoid this, we need that extra layer of validation provided by TypeScript.

So let’s initialize a Node project, install the TypeScript package, and run the TS init command to set up a project and a TypeScript default configuration file.

$ npm init -y
$ npm install typescript ts-node @types/node --save-dev
$ npx tsc --init

Now we can rename the previous file to main.ts and add type definitions for our two function parameters. We could even define the type of the return value, but the TS compiler is smart enough to infer that directly. You’ll see now that if the add function is called with incorrect parameters you’ll get the appropriate errors in your IDE.

// main.ts

function add(a: number, b: number) {
  return a + b;
}

console.log(add(1, 2));

console.log(add("Awesome", { value: 20 }));
// Argument of type 'string' is not assignable to parameter of type 'number'

Back in the terminal let’s run the TS compile command, and, if all the type checks are passing, a JavaScript file with the types stripped is generated. This is what you’ll run in your browser.

$ npx tsc
"use strict";

function add(a, b) {
  return a + b;
}

console.log(add(1, 2));

Of course, we can easily define more types and more complex logic, and the same process of type validation, compiling, and outputting JavaScript is followed.

// main.ts

type Product = {
  name: string;
  price: number;
};

function add(a: Product, b: Product) {
  return a.price + b.price;
}

const p1 = { name: "Shirt", price: 10 };
const p2 = { name: "Shoes", price: 20 };

console.log(add(p1, p2));
// main.js

"use strict";

function add(a, b) {
  return a.price + b.price;
}

const p1 = { name: "Shirt", price: 10 };
const p2 = { name: "Shoes", price: 20 };

console.log(add(p1, p2));

Now that we got the basics out of the way, let’s look at some rules to follow and mistakes to avoid when working with TypeScript.

Quick sidenote, I just explained the process of compiling TS into JS since this is needed to run your code in the browserp. However, there are numerous other runtimes like Deno, Bun and even Node with the strip types flag that offer first-class TypeScript support.

Before moving forward, let me tell you about today’s sponsor. Stream simplifies building real-time communication features so you can focus on the parts of your application that really matter. Stream’s Activity Feeds, Chat, Video, Audio APIs, and SDKs are built on GO with a Rocks DB backend, including an S F U architecture for Video and an expansive global edge network for Chat and Video.

Stream’s Android SDKs with Jetpack Compose help developers create a polished in-app chat and video experience that is reliable, scalable, and customizable.

You can try it for free at this link.

Avoid Using ANY

Ok, the first rule should be pretty obvious - avoid using the “any” type at all costs. I know, there will be those corner case scenarios where you don’t really know the shape of an object, but for all other situations you should define and use types.

As we already clarified, TypeScript’s strong point is the exact type system you would circumvent by using the “any” keyword. Whenever you are using any you are: weakening the type safety of your program and experiencing unexpected runtime errors, reducing the maintainability of your codebase due to unclear type information and losing the autocompletion and refactoring support provided by your IDE.

As I said, we probably all ran into scenarios when using any seemed like the only option. The API you are consuming might return an unknown value, a 3rd party library might not have type definitions or you are forced to work with some JavaScript modules in your TypeScript project. In such scenarios don’t hesitate to use union types and type guards to make your code safer.

let response: number | string | null;

response = getResponseFromApi();

if (typeof response === "string") {
  // Do something with the string response
} else if (typeof response === "number") {
  // Do something with the number response
}

If you are truly unsure of a variable’s type, you could use the unknown type instead of any or leave out the type reference to allow TypeScript to infer it. The unknown type requires explicit type checks before the variable can be used, which helps reduce the risk of runtime errors.

function multiply(input: unknown) {
  if (typeof input === 'number') {
    console.log(input * 2);
  } else {
    console.error('Invalid input type');
  }
}

multiply(12);

Since I mentioned type inference, remember to avoid overly verbose type annotations when they are unnecessary. So, instead of manually specifying types that can be inferred, allow TS to do its job.

//Unnecessary annotation
let name: string = "John";

// Preferred
let name = "John";

Use STRICT MODE

TypeScript offers a strict mode compiler option that enforces more rigorous type checking and constraints to enhance code quality and to catch potential issues during development. You can enable this by setting the strict flag to true in the TS config file.

//tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": {
      "@/*": [
        "./*"
      ]
    }
  }
}

Strict mode is a combination of several individual type-checking options, collectively aimed at making your TypeScript code safer and more reliable ranging from disallowing you to use the “any” typeto enforcing strict mode in the resulting JavaScript files as well.

Quick side note, JavaScript’s strict mode was introduced in ES 5 and you should always use it as well. This will allow you to opt in to a restricted variant of JavaScript where silent errors are changed to throw errors, some mistakes that make it difficult for the engine to perform optimizations are fixed, and syntax likely to be defined in future versions is prohibited.

Types vs Interfaces

Next, you should understand the strengths and limitations of types and interfaces. Yes, you can use both mechanisms to define custom types, but there are a few key differences you should keep in mind.

type MemberType = {
  id: number;
  name: string;
  email: string;
};

interface MemberInterface {
  id: number;
  name: string;
  email: string;
}

A type alias creates a new name for a type. This can be a primitive, union, intersection, tuple or any other type. In other words it can represent complex structures and give you flexibility in defining types that go beyond simple object shapes.

On the other hand, interfaces are primarily used to define the structure or shape of an object or class. Interfaces in TypeScript are more focused on describing the properties and methods that an object should have, and they provide a powerful way to enforce the structure of objects across your codebase.

As a result you should use interfaces for object shapes and class contracts, use types for complex and flexible structures and use a combination of both to create powerful type definitions.

Of course, you should be consistent. Once you choose between interfaces and types based on your specific use case you should stick with this decision across your codebase.

And, just like I explained at the beginning of this video, types and interfaces exist only at compile time and do not have a runtime representation.

Working with NULL

Non-Nullable assertions are Another big source of potential problems since they can lead to runtime errors if used incorrectly.

Again, in such scenarios you are circumventing the TS compiler, which cannot provide accurate information about a specific value. As an alternative, you should use type guards to ensure that variables are properly checked for null or undefined values before use.

function processName(name: string | null | undefined): string {
  if (name) {
    return `Hello, ${name}!`;
  } else {
    return "Hello, guest!";
  }
}

console.log(processName("Awesome")); // Output: Hello, Awesome!
console.log(processName(null));     // Output: Hello, guest!
console.log(processName(undefined)); // Output: Hello, guest!

This approach leads to safer and more reliable code.

Keep It Simple

Finally, and keep in mind that this is the most important rule in my book, keep things simple.

Overcomplicated types are a common pitfall in TypeScript projects, and although complex types may occasionally be needed, making them overly complicated can result in confusion, reduced readability, and higher maintenance costs.

type Overcomplicated<T extends { [key: string]: any }> = {
  [K in keyof T]: T[K] extends object ? Overcomplicated<T[K]> : T[K];
};

Furthermore, developers may find themselves spending more time deciphering these types than focusing on actual business logic, which can lead to slower development cycles. Simplifying types and focusing on clarity can help create a more maintainable codebase and improve collaboration among team members. So here are a few things to keep in mind:

  1. Avoid nested types since they are hard to read and maintain;
  2. Avoid complex union and intersection types since they can lead to convoluted and difficult-to-understand types;
  3. Don’t overuse mapped and conditional types since they can easily get over complicated.

If you feel like you learned something, you should watch some of my youtube videos or subscribe to the newsletter.

Until next time, thank you for reading!