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 5.3: Import Attributes and Narrowing Improvements

Explore TypeScript 5.3: import attributes, narrowing improvements, and switch(true) narrowing.

TypeScriptJavaScriptESM

By MinhVo

Introduction

TypeScript 5.3, released in November 2023, brought several important features that enhance both the developer experience and runtime behavior of TypeScript applications. The most notable addition is import attributes—a standardized syntax for providing metadata about module imports that has significant implications for JSON modules and future module formats. Additionally, TypeScript 5.3 includes meaningful improvements to control flow narrowing, particularly around switch(true) statements and conditionals following throw statements.

TypeScript 5.3 features overview

These improvements reflect TypeScript's ongoing commitment to aligning with ECMAScript standards while providing practical type-checking enhancements that developers encounter daily. Import attributes, in particular, represent a critical step forward in how JavaScript modules handle non-code resources, ensuring that runtime behavior matches what bundlers and build tools have been doing for years. Understanding these features is essential for any TypeScript developer looking to write more robust, future-proof code.

Understanding Import Attributes: The Foundation

Import attributes are a TC39 Stage 3 proposal that provides a standardized way to pass metadata to module imports. Before import attributes, different bundlers and runtimes used proprietary syntaxes—webpack's assert { type: 'json' } or Node.js's experimental --experimental-json-modules flag—to handle non-JavaScript module formats. This fragmentation created interoperability problems and made it difficult to write portable code.

The import attribute syntax uses the with keyword (replacing the earlier assert keyword from TypeScript 5.0):

import config from "./config.json" with { type: "json" };
import data from "./data.json" with { type: "json" };

This syntax tells the runtime environment how to interpret the module being imported. The type attribute specifies the MIME type or format of the module, enabling runtimes to reject or properly handle imports that don't match expected formats.

Import attributes syntax diagram

Why with Instead of assert?

TypeScript initially implemented import attributes using the assert keyword, aligning with the TC39 proposal at the time. However, the TC39 committee later changed the keyword to with to better reflect the semantics—import attributes don't just assert information about a module, they influence how the module is loaded and processed. TypeScript 5.0 through 5.2 supported both assert and with syntaxes, but TypeScript 5.3 marks the beginning of deprecating assert in favor of the standardized with keyword.

// Old syntax (deprecated, still works in 5.3 with a warning)
import legacy from "./config.json" assert { type: "json" };
 
// New syntax (preferred)
import modern from "./config.json" with { type: "json" };

Runtime Support and Compatibility

Import attributes require runtime support. As of early 2024, support varies:

RuntimeImport Attributes SupportNotes
Node.js 20.10+--experimental-json-modules flagFull support expected in Node.js 22
Chrome 123+Full supportBehind flag in earlier versions
Deno 1.38+Full supportNative implementation
BunFull supportNative implementation
Webpack 5.97+Via Rule.resolveBuild-time transformation

Narrowing Improvements: switch(true) and Control Flow

TypeScript 5.3 introduces several refinements to control flow narrowing that make type checking more intuitive and accurate. The most significant change is improved narrowing within switch(true) blocks.

switch(true) Narrowing

Before TypeScript 5.3, switch(true) statements didn't benefit from the same narrowing precision as if/else if chains. This meant that type guards within case clauses weren't properly tracked:

// Before TypeScript 5.3: value might be narrowed incorrectly
function processValue(value: string | number | boolean) {
  switch (true) {
    case typeof value === "string":
      // In 5.2: value is string | number | boolean
      // In 5.3: value is string âś“
      console.log(value.toUpperCase());
      break;
    case typeof value === "number":
      // In 5.2: value is string | number | boolean
      // In 5.3: value is number âś“
      console.log(value.toFixed(2));
      break;
    case typeof value === "boolean":
      // In 5.2: value is string | number | boolean
      // In 5.3: value is boolean âś“
      console.log(value.valueOf());
      break;
  }
}

Narrowing improvements visualization

Narrowing After Throw Statements

TypeScript 5.3 also improves narrowing in code paths that follow throw statements. When a function call is known to throw (through control flow analysis), TypeScript can now narrow types in subsequent code:

function ensureDefined<T>(value: T | undefined): T {
  if (value === undefined) {
    throw new Error("Value must be defined");
  }
  // TypeScript 5.3: value is T here
  return value;
}
 
function processData(input: string | undefined) {
  const value = ensureDefined(input);
  // TypeScript 5.3: value is string âś“
  console.log(value.toUpperCase());
}

Improved Discriminated Union Narrowing

Discriminated unions see improved narrowing in TypeScript 5.3, particularly in scenarios involving multiple type discriminators:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
  }
}

Core Architecture of the Type Checker

The TypeScript compiler's control flow analysis engine drives these narrowing improvements. Understanding how the compiler tracks types through code paths helps explain both the capabilities and limitations of these features.

Control Flow Graph Construction

The TypeScript compiler builds a control flow graph (CFG) for each function body. Each node in the graph represents a linear sequence of statements, and edges represent jumps (branches, loops, throws, returns). The type checker traverses this CFG, maintaining a type state for each variable at each program point.

// The compiler creates nodes and edges for this code:
function example(x: string | number) {
  if (typeof x === "string") {
    // Node 1: x is string (true branch)
    console.log(x.toUpperCase());
  } else {
    // Node 2: x is number (false branch)
    console.log(x.toFixed(2));
  }
  // Node 3: x is string | number (merge point)
}

Type Guard Functions and Narrowing

TypeScript 5.3 works seamlessly with user-defined type guard functions. These functions use the is keyword in their return type to signal type narrowing to the compiler:

function isString(value: unknown): value is string {
  return typeof value === "string";
}
 
function process(input: string | number) {
  if (isString(input)) {
    // TypeScript knows input is string
    console.log(input.toUpperCase());
  } else {
    // TypeScript knows input is number
    console.log(input.toFixed(2));
  }
}

Step-by-Step Implementation

Setting Up a TypeScript 5.3 Project

To use TypeScript 5.3 features, you need to update your project configuration:

# Install TypeScript 5.3
npm install typescript@5.3 --save-dev
 
# Verify installation
npx tsc --version
# Version 5.3.x

Update your tsconfig.json to use the latest ECMAScript settings:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  }
}

Working with JSON Imports

With import attributes, you can now safely import JSON files with full type safety:

// types/config.ts
interface AppConfig {
  apiUrl: string;
  timeout: number;
  retries: number;
  features: {
    darkMode: boolean;
    analytics: boolean;
  };
}
 
// config.json
{
  "apiUrl": "https://api.example.com",
  "timeout": 5000,
  "retries": 3,
  "features": {
    "darkMode": true,
    "analytics": false
  }
}
 
// app.ts
import config from "./config.json" with { type: "json" };
 
// TypeScript infers the type from the JSON structure
const apiUrl: string = config.apiUrl;
const timeout: number = config.timeout;

Type-Safe Configuration Loading

Build a robust configuration system using import attributes and type narrowing:

interface EnvironmentConfig {
  development: { apiUrl: string; debug: boolean };
  production: { apiUrl: string; debug: boolean };
  staging: { apiUrl: string; debug: boolean };
}
 
import configs from "./configs.json" with { type: "json" };
 
function loadConfig(env: keyof EnvironmentConfig) {
  if (!(env in configs)) {
    throw new Error(`Unknown environment: ${env}`);
  }
 
  // TypeScript narrows configs[env] to the correct type
  return configs[env];
}
 
const config = loadConfig("development");
console.log(config.apiUrl);  // Type-safe access

Configuration workflow

Real-World Use Cases

Use Case 1: Feature Flag Systems

Feature flags require type-safe configuration loading at runtime. Import attributes ensure that JSON-based feature flag configurations are properly typed:

import featureFlags from "./feature-flags.json" with { type: "json" };
 
interface FeatureFlag {
  enabled: boolean;
  rolloutPercentage: number;
  allowedUsers?: string[];
}
 
function isFeatureEnabled(flagName: keyof typeof featureFlags, userId?: string): boolean {
  const flag = featureFlags[flagName] as FeatureFlag;
 
  if (!flag.enabled) return false;
 
  if (flag.allowedUsers && userId) {
    return flag.allowedUsers.includes(userId);
  }
 
  if (flag.rolloutPercentage < 100 && userId) {
    const hash = hashCode(userId);
    return (hash % 100) < flag.rolloutPercentage;
  }
 
  return flag.enabled;
}

Use Case 2: Internationalization (i18n) Systems

Import attributes make loading translation files type-safe and straightforward:

import en from "./locales/en.json" with { type: "json" };
import es from "./locales/es.json" with { type: "json" };
import fr from "./locales/fr.json" with { type: "json" };
 
type TranslationKeys = keyof typeof en;
type Locale = "en" | "es" | "fr";
 
const translations = { en, es, fr } as const;
 
function t(key: TranslationKeys, locale: Locale = "en"): string {
  return translations[locale][key] ?? translations.en[key] ?? key;
}

Use Case 3: API Schema Validation

Using JSON schemas for API validation with type-safe imports:

import userSchema from "./schemas/user.json" with { type: "json" };
import orderSchema from "./schemas/order.json" with { type: "json" };
 
interface JsonSchema {
  type: string;
  properties: Record<string, { type: string; required?: boolean }>;
  required?: string[];
}
 
function validateAgainstSchema(data: unknown, schema: JsonSchema): boolean {
  if (typeof data !== "object" || data === null) {
    return schema.type === "null";
  }
 
  const obj = data as Record<string, unknown>;
  const requiredFields = schema.required ?? [];
 
  for (const field of requiredFields) {
    if (!(field in obj)) return false;
  }
 
  return true;
}

Best Practices for Production

  1. Use with over assert: Always use the new with keyword for import attributes. The assert syntax is deprecated and will be removed in future TypeScript versions. Update your codebase proactively.

  2. Define strict JSON schemas: Create TypeScript interfaces for all JSON imports to ensure compile-time type checking. Use tools like json-schema-to-typescript to automate this process.

  3. Validate at runtime: Import attributes only provide compile-time type safety. Use validation libraries like Zod or Ajv to validate JSON data at runtime, especially when loading from external sources.

  4. Leverage discriminated unions: When working with complex data structures, use discriminated unions with string literal types to enable TypeScript's narrowing improvements.

  5. Enable strict mode: Always use "strict": true in your tsconfig.json to benefit from all type-checking features, including the narrowing improvements in TypeScript 5.3.

  6. Test type guards thoroughly: Write unit tests for your type guard functions to ensure they correctly narrow types. Use tools like tsd or expect-type for type-level testing.

  7. Document import attributes: When using import attributes in shared libraries, document the supported type values and their runtime requirements.

  8. Plan for migration: If your codebase uses the deprecated assert keyword, create a migration plan. Use ts-morph or similar tools to automate the migration from assert to with.

Common Pitfalls and Solutions

PitfallImpactSolution
Using assert instead of withDeprecation warnings, future breakageUse TypeScript's --noEmit flag with assert to find and replace all instances
Importing JSON without resolveJsonModuleModule not found errorsEnable resolveJsonModule in tsconfig.json
Assuming runtime validationSecurity vulnerabilitiesAlways validate JSON imports at runtime with Zod or similar
Overusing switch(true)Less readable than if/elseReserve switch(true) for cases with multiple type guards
Missing break statements in switchUnintended fallthroughUse break or return in every case, or enable noFallthroughCasesInSwitch
Incorrect type guard return typesFalse type narrowingEnsure type predicates accurately reflect the check being performed

Performance Optimization

Import attributes have minimal runtime overhead because they're primarily a compile-time feature. However, JSON imports do load the entire file into memory:

// Efficient: Import only what you need
import { apiUrl, timeout } from "./config.json" with { type: "json" };
 
// Less efficient: Import entire config
import config from "./config.json" with { type: "json" };

For large JSON files, consider using streaming parsers or lazy loading:

async function loadLargeDataset(): Promise<DataPoint[]> {
  const response = await fetch("/data/large-dataset.json");
  const data = await response.json();
  return data as DataPoint[];
}

Comparison with Alternatives

FeatureImport AttributesDynamic import()Require
Type safetyFull TypeScript supportManual typing neededNo type safety
Static analysisYes (bundlers can analyze)LimitedNo
Browser supportGrowingFullNode.js only
JSON importsNative supportVia fetchNative
Tree shakingYesNoNo
Standard complianceTC39 Stage 3ES Module specCommonJS

Advanced Patterns

Conditional Import Attributes

Use dynamic imports with attributes for code splitting:

async function loadModuleConfig(env: string) {
  const configModule = await import(
    `./configs/${env}.json`,
    { with: { type: "json" } }
  );
  return configModule.default;
}

Type-Safe Module Resolution

Create a custom module resolution system using import attributes:

type ModuleType = "json" | "text" | "css";
 
interface ModuleConfig<T extends ModuleType> {
  path: string;
  type: T;
}
 
async function loadModule<T extends ModuleType>(
  config: ModuleConfig<T>
): Promise<T extends "json" ? object : string> {
  const module = await import(config.path, {
    with: { type: config.type }
  });
  return module.default;
}

Testing Strategies

Test your import attribute usage with comprehensive type and runtime tests:

import { describe, it, expect } from "vitest";
import config from "./config.json" with { type: "json" };
 
describe("Configuration", () => {
  it("should have correct types", () => {
    expect(typeof config.apiUrl).toBe("string");
    expect(typeof config.timeout).toBe("number");
  });
 
  it("should have valid configuration values", () => {
    expect(config.apiUrl).toMatch(/^https?:\/\//);
    expect(config.timeout).toBeGreaterThan(0);
  });
});
 
describe("Type narrowing", () => {
  it("should narrow switch(true) correctly", () => {
    function getValue(value: string | number) {
      switch (true) {
        case typeof value === "string":
          return value.toUpperCase();
        case typeof value === "number":
          return value.toFixed(2);
      }
    }
 
    expect(getValue("hello")).toBe("HELLO");
    expect(getValue(3.14)).toBe("3.14");
  });
});

Import Attributes for JSON and CSS Modules

Import attributes solve a long-standing ambiguity in JavaScript module loading: when you import a non-JavaScript file, the runtime needs to know how to interpret it. Without attributes, importing a JSON file could be parsed as a module or as a static asset depending on the runtime. The with { type: "json" } attribute explicitly declares the expected format, enabling runtimes to reject mismatched imports at parse time rather than failing with confusing runtime errors.

CSS module imports benefit from attributes as well. The with { type: "css" } attribute tells the browser to parse the import as a CSSStyleSheet object that can be adopted into the document. This pattern is part of the CSS Modules proposal that enables scoped CSS without build-time transformations. Combined with constructable stylesheets, import attributes provide a standards-based approach to CSS-in-JS that works natively in the browser.

The security implications of import attributes are significant. By requiring explicit type declarations, runtimes can enforce content security policies that prevent loading untrusted data formats. A policy might require all JSON imports to use attributes, preventing a compromised dependency from silently injecting executable code through a seemingly innocent JSON import. This defense-in-depth approach aligns with the broader trend toward explicit security boundaries in the module system.

Narrowing Improvements in Control Flow Analysis

TypeScript 5.3 improves control flow analysis for several patterns that previously required type assertions. The in operator now narrows discriminated unions more precisely, handling cases where the discriminant is a nested property accessed through optional chaining. This eliminates the need for intermediate type guards when checking for the presence of a property on a union type that might be undefined at intermediate levels.

The typeof type guard now works correctly after assignment patterns that previously confused the control flow analyzer. When you assign a value to a variable and immediately check its type, TypeScript 5.3 correctly narrows the variable in subsequent code. This improvement affects patterns like parsing configuration values where you might read a string from the environment, check if it's a valid number, and then use it as a number in subsequent code.

Switch statement narrowing also receives improvements in TypeScript 5.3. The compiler now correctly narrows types through fall-through cases and default branches, ensuring that exhaustiveness checking works correctly for complex switch statements over discriminated unions. Combined with the satisfies operator, these narrowing improvements make switch-based pattern matching a viable alternative to if-else chains for type-safe control flow.

Future Outlook

TypeScript 5.3's import attributes implementation aligns with the TC39 proposal's trajectory toward Stage 4 (full standardization). Future TypeScript versions will likely add support for additional attribute types beyond type, enabling richer metadata for module imports. The narrowing improvements in switch(true) represent an ongoing effort to make TypeScript's type checker more intuitive, reducing the need for type assertions and as casts.

The TypeScript team has signaled interest in further improving narrowing across async boundaries, which would extend these improvements to promise chains and async/await patterns.

Conclusion

TypeScript 5.3's import attributes and narrowing improvements represent meaningful advances in TypeScript's type system and module handling. The key takeaways are:

  1. Import attributes provide standardized, type-safe metadata for module imports using the with keyword
  2. switch(true) narrowing now works as intuitively as if/else chains
  3. Control flow analysis improvements reduce the need for manual type assertions
  4. Runtime validation remains essential despite compile-time type safety
  5. Migration planning from assert to with should begin immediately

These features collectively make TypeScript more reliable and easier to use, reinforcing its position as the preferred language for large-scale JavaScript applications. Update your projects to TypeScript 5.3 and start benefiting from these improvements today.