Skip to content

Module annotation for functions that return dynamic ESM imports #50314

@sinclairzx81

Description

@sinclairzx81

Suggestion

async function dynamicImport(): module { 
   switch(environment()) {
     case 'browser': return await import('./browser/index.mjs')
     case 'node': return await import('./node/index.mjs')
     default: throw Error('Environment not supported')
   }
} 

export { Foo } = await dynamicImport() // use semantics of static import

Hi, I'm not sure if this an issue, feature request or general inquiry, but I am currently having some difficulties importing and exporting class definitions through ESM dynamic imports. I have provided a minimal reproduction of the issue I'm facing at the repository link below. This setup attempts to dynamically ex-export one of many sub-modules based on information returned for the environment at runtime.

https://github.com/sinclairzx81/esm-dynamic-class-export

With the main issues shown in the code below with associated comments.

import { Agent } from '@lib/agent'

async function dynamicImport() {
    switch(Agent.resolve()) {
        case 'chrome': return await import('./chrome/index.mjs')
        case 'firefox': return await import('./firefox/index.mjs')
        case 'node': return await import('./node/index.mjs')
        default: 'Foo: Agent not supported'
    }
}

// Here we dynamically import one of the constructors using the dynamicImport()
// function above. However by importing as `const` we lose the semantics of the 
// class being imported. This means FooConstructor can not be used as a type 
// annotation in the importers implementation.
//
// example:
//
//    import { FooConstructor } from '@lib/foo'
//
//    const foo = new FooConstructor()      // ok
//
//    function test(foo: FooConstructor) {} // error: not a class
//
const { Foo: FooConstructor } = await dynamicImport()

// To address this, we create the actual intended class to be exported by 
// extending the imported FooConstructor const value. Because Foo is 
// exported as a class definition, this enables importers to observe 
// Foo with the semantics of a class.
//
// example:
//
//   import { Foo } from '@lib/foo'
//
//   const foo = new Foo()                   // ok
//
//   function test(foo: Foo) { }             // ok
//
export class Foo extends FooConstructor {}

// However, using this approach results in problems when compiling `@lib/foo` with declarations.
//
// command:
//
//   tsc -p libs/foo/tsconfig.json --outDir target/build --declaration
//
// error:
//
//  libs/foo/index.mts:13:26 - error TS4020: 'extends' clause of exported class 'Foo' has or 
//  is using private name 'FooConstructor'.   
//
//    export class Foo extends FooConstructor {}
//                             ~~~~~~~~~~~~~~

Compiled with the following.

$ tsc -p libs/foo/tsconfig.json --outDir target/build --declaration

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

🔍 Search Terms

esm dynamic export class

⭐ Suggestion

I guess it would be good if TypeScript could provide a way to hint that a functions role is to dynamically import modules and that TypeScript should use the same semantics for static imports. I think the core of the issue currently is that (afaik) the dynamically imported module needs to be imported into a const before re-export.

const { FooConstructor } = await dynamicImport() // FooConstructor is not a class from TS's perspective

export class Foo extends FooConstructor {} // this doesn't seem right 

So I guess one idea I may be to have a module return type annotation that hints to TypeScript that it should treat the functions return type similar to a static import (where imported classes are observed as class definitions, and not typeof definitions). Allowing for something like the following.

async function dynamicImport(): module {} // <-- perhaps an assertion could be added to imply that TS should treat
                                          //     the return value as a kind of static import. This would mitigate
                                          //     the need to export a runtime class definition.

export { Foo } = await dynamicImport()

📃 Motivating Example

See https://github.com/sinclairzx81/esm-dynamic-class-export

💻 Use Cases

To be able to use ESM dynamic imports to implement functionality for multiple JavaScript environments and to have TypeScript treat the dynamic import using the same semantics as a static import.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions