In TypeScript, what does it mean for an import or export to be "top-level"?

Top level import is a static import that is located at the very top of the file. However it's called "top-level" not because it is located at the top of the file, but because there are dynamic imports that are not top level:

import foo from 'foo' // top level import, static one

import('foo').then(/* ... */) // not top level import, dynamic one

// no static top-level import can live here (after any code that is not a top-level import declaration)

function bar() {
  import('foo').then(/* ... */) // not top level import, dynamic one
  // no static top-level import can live here
  // no export can live here too
}

// no static top-level import can live here

export const baz = 123 // exports are always top level, and static

// You still can add not top level imports here, in the very end
import('foo').then(/* ... */) 

Now, why this matters in Typescript?

Well if you put two files without a top-level import/export, that has two identifiers that are the same, you will get an error:

// a.ts
let foo = 1 // Error: duplicate identifier

// b.ts
let foo = 1 // Error: duplicate identifier

This happens because there are no top-level export/import declarations and TS considers these files to be scripts (in contrast to modules). And what happens if you load in a browser two scripts with same identifiers? Right, a "duplicate identifier" error will rise. Because both variables live in the global namespace.

Therefore, to avoid that you can do this:

// a.ts
let foo = 1 // Ok

// b.ts
let foo = 1 // Ok
export {} // This is the magic. b.ts is now a module and hence, is not polluting the global namespace.

The top-level in typescript is the outer most scope.

What is a "scope"?

You create a scope every time you open a set of { braces¹. Scope limits the visibility of variables and functions to the scope they are defined in and child scopes.

For example:

import { something } from "<module>"; <-- Global / Top-level scope

function divide(x, y) { <-- Start function scope
  if(y == 0) { <-- Start of the if's scope
    /* 
     * Here is a child scope of the function
     * This means x and y are available here.
     */

    var error = new Error("Cannot divide by 0); <-- "error" is only available here.
    throw error;
  } <-- End of the if's scope

  /* 
   * The "error" variable is not available here
   * since the scope it was defined in, was already closed.
   */

  return x / y;
} <-- Ends the functions scope

var z = 0; <-- Global scope

This means:

import { x } from "<module>"
/* rest of code */

works but for example:

if (true) {
  import { x } from "<module>";
}

does not work, since the import is wrapped in the scope of the if statement and therefore is not at the top-level scope anymore.

But this does not mean the "top-level" is at the top of the file. It just means the outer most scope.

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

import { x } from "<module>";

This still works, since the scope of the function ends with the closing } brace¹. Meaning everything after it is at the top-level again.

Everything said about import's also applies to export's


1: Sometimes you can omit the {|} braces to create a new scope. You still create a new scope, but do so implicitly. For example, consider the two snippets below - They are the same. Languages create scope lexically - scope is not defined by tokens

if(true)
  return true;
else
  return false;

is the same as

if(true) {
  return true;
} else {
  return false;
}

Tags:

Typescript