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.
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 requiredThis mismatch caused a cascade of problems:
- TypeScript code that compiled without errors would fail at runtime in ESM mode
- Library authors couldn't properly support both ESM and CJS consumers
- The
exportsfield inpackage.jsonwasn'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.jsonexportsandtypefields - Distinguishes between ESM and CJS files
- Supports
.mtsand.ctsfile 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 exportsUnderstanding 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, thenF<A> <: F<B>(same direction) - Contravariant: If
A <: B, thenF<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 safelyThe 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 assertionsThe 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'>; // 42Resolution 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"
}
}
}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: falseUse 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
- Use
moduleResolution: "node16"for new projects — It aligns TypeScript with Node.js behavior - Always specify file extensions in relative imports — Required for ESM, good practice everywhere
- Use
exportsmaps for library entry points — More flexible thanmainandtypesfields - Add variance annotations to generic interfaces — They prevent subtle type unsoundness
- Use
consttype parameters for literal preservation — When you need narrow type inference - Test both ESM and CJS consumers — Ensure your package works in both contexts
- Use
.mtsand.ctswhen mixing module types — Makes the intent explicit
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Missing file extensions in imports | Runtime errors in ESM | Always use .js extension in relative imports |
Wrong type field in package.json | Module loading failures | Set "type": "module" for ESM packages |
Forgetting exports map | Consumer import failures | Define explicit exports for all entry points |
| Incorrect variance annotations | Type unsoundness | Use in for inputs, out for outputs |
| Widening with generic inference | Lost literal types | Use const type parameters |
| Mixing ESM and CJS incorrectly | Runtime errors | Use explicit .mts/.cts extensions |
Comparison with Alternatives
| Feature | TS 4.7 node16 | TS moduleResolution: node | Bundler resolution |
|---|---|---|---|
| ESM support | ✅ Full | ❌ Limited | ✅ Via bundler |
exports maps | ✅ Respected | ❌ Ignored | ✅ Respected |
| File extensions | Required | Optional | Depends |
| CJS/ESM distinction | ✅ Automatic | ❌ Manual | Bundler-specific |
package.json type | ✅ Respected | ❌ Ignored | ✅ Respected |
| Node.js alignment | ✅ Exact | ❌ Approximate | N/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:
moduleResolution: "bundler"— Added in TypeScript 5.0 for bundler-specific resolution- Variance annotations are widely adopted — Used in React, Zod, tRPC, and other popular libraries
consttype parameters became standard — Used extensively in type-level programming- 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:
moduleResolution: "node16"— Aligns TypeScript with Node.js ESM behavior- Variance annotations (
in/out) — Explicitly declare type relationships for safety consttype parameters — Preserve literal types in generic inference- Instantiation expressions — Create specialized functions without wrappers
extendsoninfer— 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.