typescript
javascript
performance
optimization
daily-development
firebase
maintenance
bundlers

Consider Using Type Imports In TypeScript

I'm 100% sure that you've encountered the following import statements in your career:

import { type Functions, onCall } from 'firebase-functions';
// or
import type { Functions } from 'firebase-functions';
import { onCall } from 'firebase-functions';

You might not have had the time or inclination, like me (at least initially), to delve into this topic. But when you have nothing else to do, you might just decide, "Why not? Let's see how it works".

That moment came for me when I was making a "performance improvement" on the page you are reading. At the start of the project, I set a minimum limit in Lighthouse and used the Unlighthouse tool to build my site and measure its performance. After a few PRs with larger features, I noticed that instead of +90%, I started getting 87%, 85%, and other similar values. I was very keen to return to a score that not only pleases the eye but also the users.

So, with this slightly "story-contentful" introduction, I want to show you the benefits of using type imports in your TypeScript codebase. I will explain how it affects application size and how to enforce this practice in your project with ESLint or the TypeScript configuration file (tsconfig.json). All of this will be explained through a context-specific case, rather than a typical, dry documentation page.

Bundlers In The Context Of Type Imports

Let's say you want to use a type definition from the Firebase library on the client side. Nothing more, just a simple type to create a configuration object and export it elsewhere.

// Inside constants.ts file
// "FirebaseOptions" is a TypeScript type.
import { FirebaseOptions } from 'firebase/app';

let options: FirebaseOptions = {
  apiKey: process.env.GATSBY_API_KEY,
  authDomain: process.env.GATSBY_AUTH_DOMAIN,
  projectId: process.env.GATSBY_PROJECT_ID,
  storageBucket: process.env.GATSBY_STORAGE_BUCKET,
  messagingSenderId: process.env.GATSBY_MESSAGING_SENDER_ID,
  appId: process.env.GATSBY_APP_ID,
  measurementId: process.env.GATSBY_MEASUREMENT_ID,
};

export { options };

Modern bundlers are smart enough to recognize that it's a TypeScript type definition. Therefore, other library code like the initializeApp function inside Firebase will not be included in the final bundle. If you build this code with Webpack 5, you'll see only the size of the defined JavaScript object - options.

The interesting part is when the tree-shaking mechanism kicks in. A simple import of multiple functions does not cause the import of the entire library - just the necessary parts.

import { FirebaseOptions, initializeApp, deleteApp } from 'firebase/app';
// "FirebaseOptions" is a type.
// "initializeApp" function.
// "deleteApp" function.
// Only these 2 functions are included into bundle (in theory).

Tree shaking in Webpack is a feature that eliminates dead code from the final bundle by analyzing and removing unused exports from JavaScript modules. It relies on ES6 module syntax (import/export) to perform static analysis and ensure only the necessary code is included.

Everything works well if the library code is exported using the ES6 module syntax (import/export). But what if the library uses CommonJS (module.exports and require)? This is where the problem arises - you never know how the library is implemented until you manually check it.

Webpack is capable of handling module bundling effectively, but the tree-shaking mechanism and determining what is a type and what is not can sometimes be imperfect.

Anyway, all of this doesn't matter too much because the only important part in the context of this article is:

  1. Module bundlers are usually smart enough to determine that you've imported a type and do not include the library's JavaScript code in the bundle.
  2. There may be situations where this is not done correctly (checking manually can be a pain).
  3. There may be situations where you need to "lazy load" something, but the "type" is required sooner (we'll cover it for second).

Regarding the third point, sometimes you want to lazy load runtime code when the user performs an interaction (for example "sign-in"). What is the point of preloading code responsible for performing an HTTP request, especially when it involves heavy JavaScript, before it's actually needed?

In such scenarios, importing without the type keyword may be treated as a value import by the bundler:

Without Type Imports Bundle Size May Be Impacted

To fix this, we can use type imports. This allows the code to be fully lazy-loaded without including the Firebase code in the initial bundle.

Fixed Bundle Size 100% Not Impacted

The extension I'm using to highlight the size in Visual Studio Code is Import Cost.

Prevention Is Cheaper Than Treatment

My grandmother always used to say (because we were very poor during my childhood) that it is much easier to prevent an illness than to treat it later. The same principle applies to programming. There is already an ESLint configuration to enforce type imports, as well as a tsconfig.json compiler option.

Error From ESLint ESLint Error Message

To add it, you just need to add the following under ESLint rules:

"rules": {
  "@typescript-eslint/consistent-type-imports": [
    "error",
    {
      "prefer": "type-imports"
    }
  ]
}

Or in TypeScript, add the following property to the tsconfig.json file:

{
  "compilerOptions": {
    "importsNotUsedAsValues": "error",
    // other options
  }
}

From now on, you and your team members will be required to use type imports.

When Type Imports Saved Me?

"Saved" might be too strong a word, but I promised you a real case from an application, so here it is.

I wanted to make my sign-in logic with Firebase lazy. Instead of loading the entire library (which is quite large) and allowing easy authorization from the client, I aimed to implement a mechanism to load it only when the user performs the first interaction (mouse move, click, ...etc).

But why?

Many users on this platform are just reading content. They don't care about being authorized, so slowing down page load and interaction for the few who do want to sign in is a worthwhile trade-off (for now).

Additionally, the httpsCallable function from Firebase is imported from a completely different Firebase module - firebase-functions. So, loading it lazily is a good idea too; I don't need it at the beginning. Everything on this platform is usually statically generated.

Of course, this is my experience; yours may vary. Nonetheless, I implemented the steps mentioned above. I lazy-loaded unnecessary Firebase modules initially, and my Lighthouse score jumped back to 90%+.

Then, as an experiment, I removed the type annotation at the beginning, and interestingly, the performance dropped again. It turned out that a single type annotation in front of the import statement was as important as the lazy import syntax itself.

Here’s the code to help you understand the idea. I wanted to have communication with my API wrapped in a facade, and be able to use it in other applications (keep in mind it's only part of the code):

import type {
  API4MarkdownContractCall,
  API4MarkdownContracts,
  API4MarkdownDto,
} from 'api-4markdown-contracts';
import { type FirebaseOptions, initializeApp } from 'firebase/app';
import type { Functions } from 'firebase/functions';
import React from 'react';

type API4Markdown = {
  call: API4MarkdownContractCall;
};

let instance: API4Markdown | null = null;
let functions: Functions | null = null;

const initialize = (): API4Markdown => {
  const config: FirebaseOptions = {
    apiKey: process.env.GATSBY_API_KEY,
    authDomain: process.env.GATSBY_AUTH_DOMAIN,
    projectId: process.env.GATSBY_PROJECT_ID,
    storageBucket: process.env.GATSBY_STORAGE_BUCKET,
    messagingSenderId: process.env.GATSBY_MESSAGING_SENDER_ID,
    appId: process.env.GATSBY_APP_ID,
    measurementId: process.env.GATSBY_MEASUREMENT_ID,
  };
  const app = initializeApp(config);

  if (!instance) {
    instance = {
      call:
        <TKey extends API4MarkdownContracts['key']>(key: TKey) =>
        async (payload) => {
          const { getFunctions, httpsCallable } = await import(
            'firebase/functions'
          );

          if (!functions) {
            functions = getFunctions(app);
          }

          return (await httpsCallable(functions, key)(payload))
            .data as API4MarkdownDto<TKey>;
        },
    };
  }

  return instance;
};

const useAPI = () => React.useState(initialize)[0];

export { useAPI };

Summary

As you saw, the abstractions and great tools we use may not be perfect out of the box. Sometimes, it's beneficial to prevent potential problems if you've encountered them before to reduce costs, time, and frustration. I recommend setting up budget limits in your project related to performance. Adding these to your PR checklist as an action will make identifying performance drop-offs extremely easy.

Anyway, that's a topic for another article. For now, let's just say, my grandmother was a smart woman.

For code purists, the type import splits the "runtime" part and compile-time part, as NextJS deals with client/server code in the latest versions.

Author avatar
About Authorpolubis

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.