David East, but the top half of the face

David East

Web Developer / Designer

TypeScript's new import() types feature explained

May 23, 2018

TypeScript 2.9 introduces the import() types feature. Don’t confuse it with the dynamic import() syntax. This new feature doesn’t async import modules. It simplifies importing types in any module system.

Why do I need it?

A few months ago I ran into a problem while building an app with TypeScript and Firebase. I wasn’t doing anything fancy. I was just consuming the Firebase library from a script tag instead of using a bundler like Webpack.

<html>
  <head>
    <title>My TypeScript App</title>
    <link rel="stylesheet" href="/styles.css" />

    <!-- Include Firebase via script tags -->
    <script src="/firebase-app.js" defer></script>
    <script src="/firebase-firestore.js" defer></script>
    <!-- Application code -->
    <script src="/index.js" defer></script>
  </head>
  <body>
    <!-- App goes here -->
  </body>
</html>

I had to configure TypeScript to not use a module system since I’m using Firebase globally.

{
  "compilerOptions": {
    "module": "none",
    "outDir": "public",
    "strict": true,
    "outDir": "dist",
    "lib": ["es2017", "dom"]
  }
}

To pull in the Firebase types I attempted to import the library.

import firebase from "firebase/app";

Including the module gave me the following error:

[ts] Cannot use imports, exports, or module augmentations when '--module' is 'none'.

This error made sense. I set my module system to "none", therefore I can’t import any modules. But this is my problem.

How do I use library types without importing the library? How do I use library types in the global scope?

How do I use it?

This problem was not easily solved before TypeScript 2.9, but now we have the import() types feature. The new import() types feature allows you to include types from another file in any module context.

declare var firebase: import('firebase');

Notice that the code above is used only by the type system. The TypeScript compiler will remove this code when transpiling. It’s not an async import(), it’s just syntax that allows TypeScript to pull in types despite being in the global scope.

The example above shows how to get the types for an entire module. But import() types is quite flexible.

const app: import('firebase').app.App = firebase.initializeApp({ /* config */});

You can select a type individually if needed with dot notation.

Use it in Workers

Using TypeScript with Web or Service Workers has been a bit of pain point. Workers do not yet support ES modules. To import a dependency in a worker you must use the importScripts() API, which will not import any types.

This means there’s not a simple way of referencing a libraries types inside of worker. That is, until import() types.

// inside a web worker
importScripts('./firebase-app.js', './firebase-firestore.js');
// Firebase is now loaded!
declare var firebase: import('firebase');
const app = firebase.initializeApp({ /* config */ });
// this type is inferred, but I'm just demonstrating what you can do
const firestore: import('firebase').firestore.Firestore = app.firestore();

Using TypeScript with Workers has never been this easy.

Use it when you’re just importing types

Let’s say you’re lazy loading a library. Webpack will code-split your app into small chunks when you use the dynamic import() (again, different than import() to import types. Confusing but different).

// TypeScript will remove this import
// since I'm only using types, but...
// This is dangerous territory ⚠️
import firebase from 'firebase/app';

async function loadFirebaseApp() {
  const firebase = await import('firebase');
  await import('firebase/firestore');
  return firebase.initializeApp({ /* config */ });
}
// Retrieve a collection of users from Firestore
// Note: Firebase is loaded lazily
// Note: Plz only use the import for types. plz. 🙏
function getUserList(app: firebase.app.App) {
  return app.firestore().collection('users').get();
}

loadFirebaseApp()
  .then(getUserList)
  .then(users => console.log(users))

The code above is asynchonously loading the Firebase and Firestore SDKs. This way Webpack will take Firebase and Firestore out of the main bundle. I have to be careful if I include the Firebase module as a top level import. TypeScript is smart enough to remove imports that only use types. However, once it’s a top level import there’s a chance it will get misused. TypeScript will keep the import if I accidentally use a library feature from the top level import.

I want to ensure that Firebase does not end up in the main bundle. I want to communicate that I am using the library asynchonously. This is another use for import() types.

// Look! No top level import!
async function loadFirebaseApp() {
  const firebase = await import('firebase');
  await import('firebase/firestore');
  return firebase.initializeApp({ /* config */ });
}
// Retrieve a collection of users from Firestore
// Note: Firebase is loaded lazily
// Note: Only use the import('') types syntax to keep
//       Firebase out of the main bundle! 🙌
function getUserList(app: import('firebase').app.App) {
  return app.firestore().collection('users').get();
}

loadFirebaseApp()
  .then(getUserList)
  .then(users => console.log(users))

The code above does not contain a top level import for Firebase. There’s no chance I’ll accidentally use a library feature instead of the types. This not only keeps Firebase out of my main bundle but it also communicates to anyone reading the code. It explicitly tells the reader that I am using Firebase asynchonously.

Importing types just got a lot more flexible

The main take-away is that you can now import types in any module context without needing an explicit module import. Use it with Workers. Use it in the global scope. Use it when you just need the types without importing the actual module code.