Skip to content

Don't break out of type safety

Writing type safe code using TypeScript highly improves code quality and developer experience. But people often break out of type safety, writing code that isn't strictly type safe anymore. Here are a few tips & tricks for using TypeScript.

Published
Feb 12, 2023
Updated
Feb 12, 2023
Reading time
9 min read

Strict Mode

TypeScript has a strict flag which enables additional type checks, resulting in stronger guarantees of program correctness. It's highly recommended enabling this flag in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

One of the additional behaviors is the stricter null check. Without strict mode you can use variables that are possibly null without checking if they are null.

// "strict": false
function foo(bar: string | null) {
  // Runtime error if `bar` is null
  console.log(bar.toUpperCase());
}

// "strict": true
function foo(bar: string | null) {
  // Not allowed, TS shows an error
  console.log(bar.toUpperCase());

  // How to fix: check if `bar` is a string
  if (typeof bar === "string") {
    console.log(bar.toUpperCase());
  }

  // or, if you want to log `null`
  console.log(bar?.toUpperCase());
}

Accessing array elements

TypeScript allows you to access an array element without checking if the element actually exists:

function foo(items: string[]) {
  // TypeScript is happy with that
  console.log(items[0].toUpperCase());
}

In the example above, the function will throw an error if the items array is empty, because items[0] returns undefined and doesn't have a toUpperCase function. TypeScript doesn't warn you about that, regardless of whether strict mode is enabled or not.

The flag noUncheckedIndexedAccess helps you to catch these kinds of bugs. Enabling it will add undefined to a type:

function foo(items: string[]) {
  const [first] = items;
  if (first !== undefined) {
    console.log(item.toUpperCase());
  }
}

Type casting objects

I admit it: type casting is sometimes necessary. But some developers use it to type objects like this:

interface User {
  id: number;
  firstName: string;
  lastName: string;
}

const user = {
  id: 1,
  firstName: "John",
} as User;

This approach is very problematic. The object isn't a valid User object - the lastName property is missing. Avoid this syntax and use the type safe way instead:

const user: User = {
  id: 1,
  firstName: "John",
  lastName: "Doe",
};

Type guards

In the following example the variable items is an array of strings, but some elements are null. You now want to remove the null values:

const items = ["one", null, "two", "three"];
const validItems = items.filter((item) => {
  return typeof item === "string";
}) as string[];

The filter() function removes all non-string values, but TypeScript doesn't understand this code and validItems is still of type Array<string | null> - that's why we type cast the value. But again, we should avoid type casting using the x as y syntax.

Fortunately, there is a cleaner approach using type guards. A type guard is a function that returns a boolean value and narrows the type of a variable. We can use a type guard to remove null values:

const validItems = items.filter((item): item is string => {
  return typeof item === "string";
});

The function provided to filter() now returns whether item is of type string. TypeScript now understands that validItems is an array of strings without null values.

Unhandled cases in switch statement

The following function foo uses a switch statement and returns a different value depending on the value of color:

type Color = "primary" | "secondary";

function foo(color: Color) {
  switch (color) {
    case "primary":
      return {
        /* ... */
      };
    case "secondary":
      return {
        /* ... */
      };
  }
}

Do you spot the problem in the example above? If one day, the Color type is extended with a new value, let's say neutral, the function will not work properly anymore. Depending on how the function is used you may get a compiler error, but that's not guaranteed.

Whenever you use a switch statement, make sure to handle all cases. There's a trick using the never type to assert that all cases are handled:

function foo(color: Color) {
  switch (color) {
    case "primary":
      return {
        /* ... */
      };
    case "secondary":
      return {
        /* ... */
      };
    default:
      assertUnreachable(color);
  }
}

function assertUnreachable(_value: never): never {
  throw new Error("Statement should be unreachable");
}

If all cases are handled, color will be of type never in the default case. If a new color is added, the compiler will show an error:

Argument of type 'string' is not assignable to parameter of type 'never'.

Unions

Let's say you have two interfaces, EmailLink and WebLink.

interface WebLink {
  type: LinkType.WEB;
  url: string;
}

interface EmailLink {
  type: LinkType.EMAIL;
  email: string;
}

Now you want to have a generic type Link that supports both types. You could create an interface and extend the two existing interfaces with it:

interface Link {
  type: LinkType;
}

interface EmailLink extends Link {
  /* ... */
}
interface WebLink extends Link {
  /* ... */
}

This approach works but is problematic as it doesn't support type inference. Given a function openLink() that receives a link, you have to type cast the object based on the type property:

function openLink(link: Link) {
  if (link.type === LinkType.EMAIL) {
    const emailLink = link as EmailLink;
    // ...
  } else if (link.type === LinkType.WEB) {
    const webLink = link as WebLink;
    // ...
  }
}

A better way is using a union type which automatically infers the type when checking the type property without the need for type casting:

type Link = WebLink | EmailLink;

Now the openLink() function doesn't have to type cast the link:

function openLink(link: Link) {
  if (link.type === LinkType.EMAIL) {
    // TypeScript knows that `link` is an EmailLink
    window.open(`mailto:${link.email}`);
  } else if (link.type === LinkType.WEB) {
    // TypeScript knows that `link` is a WebLink
    window.open(link.url);
  }
}

Translations

TypeScript is not just for your application code but for your translations too. If and how to use TypeScript for translations depends on how you handle translations and what library you use.

If you use an extraction tool, that automatically creates the translation files from your code base, you may not need (or be able) to use TypeScript.

Some translation libraries like i18next provide a way to work with translation keys in a type safe way. Other libraries like Transloco for Angular don't support this out of the box.

Translation files don't have to be JSONs, you can use TypeScript files too. This way you can ensure that all translations have the same keys and no translations are missing in a language. In addition, you can define a TranslationKey type that gives you all valid keys (which is possible with JSONs too).

// en-US.ts
const enUS = {
  key1: "Hello",
  key2: "Goodbye",
};

export type Translations = typeof enUS;
export type TranslationKey = keyof Translations;

// de-DE.ts
import type { Translations } from "./en-US";

// TypeScript warns you about the missing key `key2`
const deDE: Translations = {
  key1: "Hallo",
};

Now let's say you have a Status enum and want to display a translated label in the UI. Thanks to the TranslationKey type you can ensure that a translation exists for each enum value:

const label: TranslationKey = `some-component.status.${myEnumValue}`;

You can also make it more explicit if you want:

type StatusTranslation =
  `some-component.status.${Status}` extends TranslationKey
    ? `some-component.status.${Status}`
    : never;

const label: StatusTranslation = `some-component.status.${Status.Draft}`;

Satisfies operator

TypeScript 4.9 introduced the new satisfies operator. Using the satisfies operator you can ensure an object is of a given type while preserving the actual type of the object.

Given the following User interface and user object:

interface User {
  firstName: string;
  lastName: string;
  metadata: Record<string, string>;
}

const user: User = {
  firstName: "",
  lastName: "",
  metadata: {
    pet: "Cat",
  },
};

The user object is now of type User and user.metadata resolves to a generic Record<string, string> object. Accessing user.metadata.pet is not type safe anymore.

// Not type safe anymore
console.log(user.metadata.pet);

// `dog` doesn't exist but no error :(
console.log(user.metadata.dog);

So far we had to use functions to improve type safety for such use cases (which is still useful if you use an older TypeScript version):

function createUser<U extends User>(user: U) {
  return user;
}

const user = createUser({
  firstName: "John",
  lastName: "Doe",
  metadata: { pet: "Cat" },
});

// Type safe ✅
console.log(user.metadata.pet);

With satisfies we don't need the runtime function:

const user = {
  firstName: "John",
  lastName: "Doe",
  metadata: { pet: "Cat" },
} satisfies User;

// Type safe ✅
console.log(user.metadata.pet);

Template literals and primitive types

The example below is a function that expects an ISO date string and formats the date.

function formatDate(isoDateString: string) {
  const formattedDate = "...";
  return formattedDate;
}

The function accepts an ISO date string but uses the generic string primitive type. To make your code more expressive you should create an alias type for it:

type DateISOString = string;

function formatDate(date: DateISOString) {
  const formattedDate = "...";
  return formattedDate;
}

However, there is a limitation: when calling the function your IDE may still just show string instead of DateISOString. But it's still helpful when reading the function code.

If a value is not just any string but has a specific format, you can use template literal types to make the format more explicit. For the DateISOString this would be something like this:

type DateISOString =
  `${number}-${number}-${number}T${number}:${number}:${number}.${number}Z`;

This type doesn't enforce a valid ISO string, you can still pass an invalid date like 0-0-0T0:0:0.000Z. But it helps developers to know the expected format.

Template literal types are useful to define the expected format instead of using a generic string. That doesn't mean you should always use them. In case of an ISO string it's probably better to use just string. The expected format is known anyway (not something specific to your application) and it's unlikely that you manually create a string, but you will use something like date.toISOString() instead.


That's it. I hope you learned something new and the tips help you to write better TypeScript code.