MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

TypeScript 4.7: ESM Support and Variance Annotations

Explore TypeScript 4.7: ESM support, const type parameters, variance annotations, and more.

TypeScriptESMJavaScript

By MinhVo

Introduction

TypeScript 4.7, released in May 2022, was a landmark release that addressed one of the most requested features in the TypeScript ecosystem: native ESM (ECMAScript Modules) support in Node.js. Alongside this, it introduced const type parameters for preserving literal types, variance annotations for explicit type relationship declarations, and numerous other improvements. These features collectively made TypeScript more aligned with the JavaScript specification and more powerful for type-level programming.

The ESM support alone was transformative. For years, the TypeScript community had struggled with the mismatch between TypeScript's module resolution and Node.js's evolving module system. TypeScript 4.7 finally bridged this gap with the moduleResolution: "node16" option, enabling proper support for .mts and .cts file extensions, package.json exports maps, and the distinction between ESM and CJS contexts.

This guide covers every major feature in TypeScript 4.7, with practical examples, migration strategies, and the subtle behaviors that matter in production.

TypeScript module system

Understanding ESM Support in TypeScript 4.7

The Problem: TypeScript and Node.js Module Mismatch

Before TypeScript 4.7, there was a fundamental disconnect between how TypeScript resolved modules and how Node.js actually loaded them:

// TypeScript (before 4.7) allowed this:
import { readFile } from './utils';  // No extension
 
// But Node.js ESM requires this:
import { readFile } from './utils.js';  // Extension required

This mismatch caused a cascade of problems:

  1. TypeScript code that compiled without errors would fail at runtime in ESM mode
  2. Library authors couldn't properly support both ESM and CJS consumers
  3. The exports field in package.json wasn't properly respected

The moduleResolution: "node16" Setting

TypeScript 4.7 introduced moduleResolution: "node16" (and later "nodenext"), which aligns TypeScript's module resolution with Node.js's actual behavior:

// tsconfig.json
{
  "compilerOptions": {
    "module": "node16",
    "moduleResolution": "node16",
    "target": "es2022",
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

With this configuration, TypeScript now:

  • Requires file extensions in relative imports
  • Respects package.json exports and type fields
  • Distinguishes between ESM and CJS files
  • Supports .mts and .cts file extensions

Package.json type Field

The type field in package.json determines whether .js files are treated as ESM or CJS:

// ESM package
{
  "name": "my-esm-package",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
 
// CJS package (default)
{
  "name": "my-cjs-package",
  // type defaults to "commonjs"
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  }
}

ESM vs CJS File Extensions

TypeScript 4.7 introduced new file extensions to explicitly mark module type:

// src/index.ts → src/index.js (inherits package type)
// src/index.mts → src/index.mjs (always ESM)
// src/index.cts → src/index.cjs (always CJS)
 
// Example: ESM module
// src/utils.mts
export function helper(): string {
  return 'ESM helper';
}
 
// Example: CJS module
// src/legacy.cts
module.exports = {
  helper(): string {
    return 'CJS helper';
  }
};

The exports Map

The exports field in package.json is the modern way to define package entry points:

{
  "name": "my-library",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.js",
      "require": "./dist/utils.cjs"
    },
    "./package.json": "./package.json"
  }
}

TypeScript 4.7 now properly resolves these paths:

// Consumer code (ESM)
import { helper } from 'my-library';        // ✅ Resolves via exports
import { util } from 'my-library/utils';     // ✅ Resolves via exports
 
// Consumer code (CJS)
const { helper } = require('my-library');    // ✅ Resolves via exports

Module resolution

Understanding Variance Annotations

What Is Variance?

In type theory, variance describes how subtype relationships between complex types relate to subtype relationships between their component types. There are four kinds of variance:

  • Covariant: If A <: B, then F<A> <: F<B> (same direction)
  • Contravariant: If A <: B, then F<B> <: F<A> (reversed)
  • Bivariant: Both covariant and contravariant
  • Invariant: Neither covariant nor contravariant
// Covariant example: return types
// Dog <: Animal → () => Dog <: () => Animal ✅
 
// Contravariant example: parameter types
// Dog <: Animal → (x: Animal) => void <: (x: Dog) => void ✅
 
// Invariant example: read-write containers
// Neither direction works safely

The Problem Before 4.7

TypeScript's variance inference was sometimes incorrect or inconsistent, leading to confusing errors:

// Before TypeScript 4.7
interface Producer<T> {
  produce(): T;
}
 
// TypeScript incorrectly inferred this as invariant
// Leading to errors like:
const dogProducer: Producer<Dog> = { produce: () => new Dog() };
const animalProducer: Producer<Animal> = dogProducer; // ❌ Error in strict mode
 
// Developers had to work around with type assertions

The in and out Keywords

TypeScript 4.7 introduced in and out variance annotations to explicitly declare variance:

// Covariant: T only appears in output positions
interface Producer<out T> {
  produce(): T;
  // T is only used as a return type
}
 
// Contravariant: T only appears in input positions
interface Consumer<in T> {
  consume(value: T): void;
  // T is only used as a parameter type
}
 
// Invariant: T appears in both input and output positions
interface Transformer<in out T> {
  transform(value: T): T;
  // T is used in both positions
}

Practical Variance Annotation Examples

// Event handler: contravariant in event type
interface EventHandler<in E> {
  handle(event: E): void;
}
 
// Event emitter: covariant in event type
interface EventEmitter<out E> {
  getEvent(): E;
}
 
// Read-write store: invariant
interface Store<in out T> {
  get(): T;
  set(value: T): void;
}
 
// Readonly array: covariant
interface ReadonlyArray<out T> {
  readonly length: number;
  readonly [n: number]: T;
  map<U>(fn: (value: T) => U): ReadonlyArray<U>;
}
 
// Mutable array: invariant (because of push, pop, etc.)
interface MutableArray<in out T> extends ReadonlyArray<T> {
  push(...items: T[]): number;
  pop(): T | undefined;
  [n: number]: T;
}

Benefits of Variance Annotations

// 1. Better error messages
interface Handler<in T> {
  handle(value: T): void;
}
 
// TypeScript will now give a clear error about contravariance
const stringHandler: Handler<string> = { handle: (s) => console.log(s) };
const numberHandler: Handler<number> = stringHandler; // ❌ Clear error
 
// 2. Safer type relationships
interface Factory<out T> {
  create(): T;
}
 
const dogFactory: Factory<Dog> = { create: () => new Dog() };
const animalFactory: Factory<Animal> = dogFactory; // ✅ Correctly allowed
 
// 3. Better inference
function process<out T>(items: readonly T[]): T {
  return items[0];
}

Const Type Parameters

The Problem with Type Inference

Before TypeScript 4.7, when you passed a literal value to a generic function, TypeScript widened the type:

// Before TypeScript 4.7
function createPair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}
 
const pair = createPair('hello', 42);
// Type: [string, number]
// We lost the literal type 'hello'!

The const Modifier

TypeScript 4.7 introduced const type parameters, which tell TypeScript to infer the narrowest possible type (similar to as const):

// TypeScript 4.7
function createPair<const A, const B>(a: A, b: B): [A, B] {
  return [a, b];
}
 
const pair = createPair('hello', 42);
// Type: [readonly ["hello", 42]]
// The literal types are preserved!

Practical Const Type Parameter Examples

// Preserving object literal types
function defineRoutes<const T extends Record<string, () => void>>(routes: T): T {
  return routes;
}
 
const routes = defineRoutes({
  home: () => {},
  about: () => {},
  contact: () => {},
});
// Type: { readonly home: () => void; readonly about: () => void; readonly contact: () => void }
 
// Preserving tuple types
function createConfig<const T extends readonly unknown[]>(items: T): T {
  return items;
}
 
const config = createConfig(['debug', 3000, true]);
// Type: readonly ["debug", 3000, true]
 
// Type-safe enum-like objects
function defineStatuses<const T extends Record<string, string>>(statuses: T): T {
  return statuses;
}
 
const statuses = defineStatuses({
  pending: 'PENDING',
  active: 'ACTIVE',
  completed: 'COMPLETED',
});
// Type: { readonly pending: "PENDING"; readonly active: "ACTIVE"; readonly completed: "COMPLETED" }

Without vs With const

// Without const: types are widened
function identity<T>(value: T): T { return value; }
const widened = identity({ x: 10, y: 20 });
// Type: { x: number; y: number }
 
// With const: types are narrow
function identityConst<const T>(value: T): T { return value; }
const narrowed = identityConst({ x: 10, y: 20 });
// Type: { readonly x: 10; readonly y: 20 }
 
// Practical difference with index signatures
type Keys = keyof typeof narrowed; // "x" | "y"
type WidenedKeys = keyof typeof widened; // "x" | "y" (same, but literal types matter elsewhere)

Instantiation Expressions

The Feature

TypeScript 4.7 introduced instantiation expressions, allowing you to specify type arguments without calling a function:

// Before: had to create a wrapper function
function createStringProcessor() {
  return process<string>;
}
 
// TypeScript 4.7: instantiation expressions
const stringProcessor = process<string>;
const numberProcessor = process<number>;
 
// Usage
stringProcessor('hello'); // ✅
numberProcessor(42);      // ✅

Practical Examples

// Creating specialized factory functions
function createFactory<T>(type: new () => T): () => T {
  return () => new type();
}
 
const createUser = createFactory<User>;
const createProduct = createFactory<Product>;
 
const user = createUser();    // Type: User
const product = createProduct(); // Type: Product
 
// Event handling
type EventHandler<T> = (event: T) => void;
 
function on<T>(type: string, handler: EventHandler<T>): void {
  // Register handler
}
 
const onClick = on<MouseEvent>;
const onKeydown = on<KeyboardEvent>;
 
onClick('click', (event) => {
  event.clientX; // ✅ MouseEvent
});
 
// Middleware chains
type Middleware<T> = (input: T, next: () => void) => void;
 
function createMiddleware<T>(): Middleware<T>[] {
  return [];
}
 
const authMiddleware = createMiddleware<AuthContext>;
const loggingMiddleware = createMiddleware<RequestContext>;

Other TypeScript 4.7 Improvements

extends Constraints on infer

TypeScript 4.7 allows extends constraints on infer type variables:

// Extract the element type of an array, but only if it's a string
type ElementType<T> = T extends (infer U extends string)[] ? U : never;
 
type Result = ElementType<string[]>; // string
type Result2 = ElementType<number[]>; // never (number doesn't extend string)
 
// Extract method names from a type
type MethodNames<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never;
}[keyof T];
 
// More precise template literal types
type ExtractId<T> = T extends `id_${infer Id extends number}` ? Id : never;
type Id = ExtractId<'id_42'>; // 42

Resolution Customization with package.json

{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      "types@4.7": "./dist/index-4.7.d.ts",
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

TypeScript configuration

Real-World Use Cases

Use Case 1: Dual ESM/CJS Package Publishing

// package.json
{
  "name": "my-library",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "scripts": {
    "build:esm": "tsc -p tsconfig.esm.json",
    "build:cjs": "tsc -p tsconfig.cjs.json",
    "build": "npm run build:esm && npm run build:cjs"
  }
}
 
// tsconfig.esm.json
{
  "compilerOptions": {
    "module": "node16",
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src/**/*.mts"]
}
 
// tsconfig.cjs.json
{
  "compilerOptions": {
    "module": "node16",
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src/**/*.cts"]
}

Use Case 2: Type-Safe Configuration with Const Types

function defineConfig<const T extends {
  readonly databases: readonly string[];
  readonly features: Record<string, boolean>;
}>(config: T): T {
  return config;
}
 
const config = defineConfig({
  databases: ['postgres', 'redis', 'elasticsearch'],
  features: {
    darkMode: true,
    notifications: false,
    analytics: true,
  },
});
 
// TypeScript knows the exact types:
// config.databases: readonly ["postgres", "redis", "elasticsearch"]
// config.features.darkMode: true
// config.features.notifications: false

Use Case 3: Variance-Safe API Design

// Read-only repository: covariant in T
interface ReadOnlyRepository<out T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
}
 
// Write-only repository: contravariant in T
interface WriteOnlyRepository<in T> {
  save(entity: T): Promise<void>;
  delete(id: string): Promise<void>;
}
 
// Full repository: invariant
interface Repository<in out T> extends ReadOnlyRepository<T>, WriteOnlyRepository<T> {}
 
// This allows safe substitution
const dogRepo: ReadOnlyRepository<Dog> = { /* ... */ };
const animalRepo: ReadOnlyRepository<Animal> = dogRepo; // ✅ Safe!

Best Practices for Production

  1. Use moduleResolution: "node16" for new projects — It aligns TypeScript with Node.js behavior
  2. Always specify file extensions in relative imports — Required for ESM, good practice everywhere
  3. Use exports maps for library entry points — More flexible than main and types fields
  4. Add variance annotations to generic interfaces — They prevent subtle type unsoundness
  5. Use const type parameters for literal preservation — When you need narrow type inference
  6. Test both ESM and CJS consumers — Ensure your package works in both contexts
  7. Use .mts and .cts when mixing module types — Makes the intent explicit

Common Pitfalls and Solutions

PitfallImpactSolution
Missing file extensions in importsRuntime errors in ESMAlways use .js extension in relative imports
Wrong type field in package.jsonModule loading failuresSet "type": "module" for ESM packages
Forgetting exports mapConsumer import failuresDefine explicit exports for all entry points
Incorrect variance annotationsType unsoundnessUse in for inputs, out for outputs
Widening with generic inferenceLost literal typesUse const type parameters
Mixing ESM and CJS incorrectlyRuntime errorsUse explicit .mts/.cts extensions

Comparison with Alternatives

FeatureTS 4.7 node16TS moduleResolution: nodeBundler resolution
ESM support✅ Full❌ Limited✅ Via bundler
exports maps✅ Respected❌ Ignored✅ Respected
File extensionsRequiredOptionalDepends
CJS/ESM distinction✅ Automatic❌ ManualBundler-specific
package.json type✅ Respected❌ Ignored✅ Respected
Node.js alignment✅ Exact❌ ApproximateN/A

Testing Strategies

// Test ESM imports
import { describe, it, expect } from 'vitest';
import { helper } from './utils.mjs';
 
describe('ESM Support', () => {
  it('imports work with extensions', () => {
    expect(helper()).toBe('result');
  });
 
  it('respects exports map', async () => {
    const mod = await import('my-library');
    expect(mod.default).toBeDefined();
  });
});
 
// Test variance annotations
describe('Variance Annotations', () => {
  it('covariant types allow substitution', () => {
    const dogProducer: Producer<Dog> = { produce: () => new Dog() };
    const animalProducer: Producer<Animal> = dogProducer; // Should work
    expect(animalProducer.produce()).toBeInstanceOf(Dog);
  });
});
 
// Test const type parameters
describe('Const Type Parameters', () => {
  it('preserves literal types', () => {
    const config = createConfig(['debug', 3000, true]);
    type Config = typeof config;
    // Assert readonly tuple with literal types
    expect(config[0]).toBe('debug');
  });
});

Module Resolution Strategies in Practice

TypeScript 4.7 introduced the moduleResolution option values "node16" and "nodenext" that accurately reflect how Node.js resolves ESM and CJS modules. The key difference from the legacy "node" resolution is that "node16" enforces strict file extension requirements for ESM imports. Relative imports must include the .js extension even though the source file is .ts, because TypeScript does not transform import specifiers during compilation. This catches a common runtime error at compile time where ESM imports without extensions fail in Node.js.

The "bundler" module resolution mode added later in TypeScript 5.0 relaxes these restrictions for projects that use bundlers like webpack or Vite, which handle module resolution independently. Choosing the correct module resolution strategy depends on your runtime target: use "node16" or "nodenext" for direct Node.js execution, "bundler" for bundled applications, and "classic" only for legacy AMD module systems.

Package.json "exports" field support in TypeScript 4.7 enables libraries to define conditional exports that serve different entry points for ESM and CJS consumers. The compiler reads the "exports" map to resolve import paths, ensuring type checking respects the same resolution rules as the runtime. This eliminates the need for separate typesVersions configuration and ensures that the types match the actual module that Node.js loads at runtime.

Variance Annotations for Complex Generic Types

Variance annotations (in and out keywords on type parameters) tell TypeScript how generic types relate to each other in terms of subtype relationships. A type parameter annotated with out is covariant, meaning Container<Dog> is a subtype of Container<Animal> if Dog extends Animal. A type parameter annotated with in is contravariant, meaning a handler that accepts Animal is a subtype of a handler that accepts Dog. Combined in out indicates invariance, where no subtype relationship exists.

These annotations improve type checking performance by eliminating the need for TypeScript to compute variance through expensive structural comparisons. Without annotations, TypeScript must analyze all usages of a type parameter to determine its variance, which can be slow for deeply nested generic types. Explicit annotations skip this analysis entirely, resulting in faster compilation for projects with complex generic hierarchies.

The practical impact is most visible in callback-heavy APIs where contravariance is common. A function that accepts Comparator<Animal> should accept Comparator<Dog> because comparing any two animals is sufficient for comparing two dogs. Without the in annotation, TypeScript might reject this assignment due to bivariant function parameter checking. The annotation makes the intended variance explicit and enables TypeScript to enforce the correct relationship.

Future Outlook

TypeScript 4.7's features have evolved significantly:

  1. moduleResolution: "bundler" — Added in TypeScript 5.0 for bundler-specific resolution
  2. Variance annotations are widely adopted — Used in React, Zod, tRPC, and other popular libraries
  3. const type parameters became standard — Used extensively in type-level programming
  4. ESM adoption is accelerating — Most modern packages now ship ESM-first

Conclusion

TypeScript 4.7 was a pivotal release that bridged the gap between TypeScript and Node.js module systems while introducing powerful type-level features. The ESM support enabled proper package publishing, variance annotations made generic types safer, and const type parameters gave developers precise control over type inference.

Key takeaways:

  1. moduleResolution: "node16" — Aligns TypeScript with Node.js ESM behavior
  2. Variance annotations (in/out) — Explicitly declare type relationships for safety
  3. const type parameters — Preserve literal types in generic inference
  4. Instantiation expressions — Create specialized functions without wrappers
  5. extends on infer — More precise conditional types

If you're publishing TypeScript packages or working with ESM, TypeScript 4.7's features are essential. The module system improvements alone justify upgrading, and the type-level features make TypeScript's type system more expressive and safer.