Alright, let’s talk about TypeScript 4.7. This is where the team looked at the Node.js ecosystem’s awkward, decade-long tango with ES modules and said, “Fine, we’ll do it ourselves.” And then, because they’re overachievers, they threw in a feature called Instantiation Expressions that is so clever it almost makes up for the fact that we have to deal with two module systems in the first place.

The Node.js ESM Support We Deserved

For years, using ES modules in Node.js with TypeScript felt like trying to fit a square peg into a round hole while everyone argues about the definition of “round.” You had to use esModuleInterop, maybe allowSyntheticDefaultImports, and then pray. TypeScript 4.7 finally introduced a clean, first-class way to configure this madness.

The magic lies in two new tsconfig.json fields: module and moduleResolution.

// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext", // or "ESNext"
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    // ... other settings
  }
}

By setting moduleResolution to "NodeNext", you tell TypeScript to respect the "type" field in your package.json. This is a game-changer.

// package.json
{
  "name": "my-cool-app",
  "type": "module", // This means .js files are ESM!
  "exports": "./dist/index.js"
}

With this setup, a .ts file is now treated as an ESM module. The rules you’ve had to memorize finally apply: import requires a full extension (or falls prey to Node’s resolution algorithm), and import.meta.url just works.

// ./src/utils.ts
// This is an ESM module because of package.json "type": "module"

// You MUST use the full relative path extension. This isn't the browser.
import { helper } from './helper.js'; // ✅ Correct
import { helper } from './helper';    // ❌ Error in ESM mode!

// And you can finally use import.meta.url correctly
export const __filename = import.meta.url;

The flip side is, if you have a package.json with "type": "commonjs", your files are treated as CommonJS. This explicit, project-wide definition is infinitely clearer than the previous guessing game. The best practice? Pick one. Decide if your project is ESM or CJS at the root level and configure TypeScript accordingly. Mixing and matching within a single project is a fast track to migraines.

Instantiation Expressions: Generics, But Make Them Reusable

Now, for the genuinely cool part. Instantiation Expressions solve a problem you’ve definitely had but probably just lived with: the inability to create a specific, pre-configured instance of a generic function or class without wrapping it in another function.

Let’s say you have a generic error mapper.

function mapErrors<T>(error: unknown, to: new (msg: string) => T): T | null {
  if (error instanceof Error) {
    return new to(error.message);
  }
  return null;
}

Before 4.7, if you wanted a function that always mapped to a TypeError, you had to create a wrapper.

// The old, clunky way
function mapToTypeError(error: unknown) {
  return mapErrors(error, TypeError);
}
const result = mapToTypeError(someError); // result is TypeError | null

It works, but it’s boilerplate. Instantiation Expressions let you bake the generic parameter directly into a variable. It’s like a partial application, but for types.

// The new, slick way
const mapToTypeError = mapErrors<TypeError>; // ✅ Instantiation Expression

// It's the same as writing:
// const mapToTypeError = (error: unknown) => mapErrors(error, TypeError);
const result = mapToTypeError(someError); // result is TypeError | null

See what happened? mapToTypeError is no longer a generic function. It’s a concrete function with the type (error: unknown) => TypeError | null. The generic parameter <TypeError> is locked in.

This is incredibly powerful for defining reusable, specific utility functions on the fly. The best practice here is to use it to reduce boilerplate and create more expressive, self-documenting code. Instead of passing Array<string> around everywhere, you can create a type alias type StringArray = Array<string> and then use an instantiation expression like const createStringArray = Array<string>;. It makes your intent crystal clear.

The main pitfall? It’s a subtle feature. Someone reading your code might see mapErrors<TypeError> and think it’s a generic function call, not a value declaration. Use it judiciously and where it significantly improves clarity, not just to show off that you know it exists. It’s a scalpel, not a sledgehammer.