TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Computed import types

Open reverofevil opened this issue 4 years ago • 10 comments

Suggestion

🔍 Search Terms

import label:feature-request

✅ Viability Checklist

My suggestion meets these guidelines:

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

⭐ Suggestion

Add ability to typeof import() a string literal type

📃 Motivating Example

declare module 'test' {
    export const test: number;
}

type GetExport<T extends string> = typeof import(T)

type Test = GetExport<'test'> // typeof import('test') ≈ {test: number}

💻 Use Cases

babel, eslint and webpack use package names in their API, dynamically require them and proxy data into their interface. Generally, API that looks like

doSomething({
    package: 'package-name',
    options: {
        optionForThisPackage: 'value',
    },
});

could greatly improve on typing with this.

Also it's often the case with dynamic imports that resulting Promise gets passed through several other methods (like React.lazy or loadable from @loadable/component). Currently the only way to get good types there is to have these duplicated at every import() site. It would be possible to assign some types to wrapper function in this case too.

type LazyRegistry<Packages extends Record<string, string>> = {
    [Name in keyof Packages]: Promise<(typeof import(Name))[Packages[Name]]>
}
const lazyRegistry = <Packages extends Record<string, string>>(packages: Packages): LazyRegistry<Packages> => {
    return Object.fromEntries(
        Object.entries(packages).map(([name, importName]) => {
            const lazy = React.lazy(async () => {
                return (await import(name))[importName];
            });
            return [key, lazy] as const;
        }),
    ) as LazyRegistry<Packages>;
};

type LoadableRegistry<Packages extends Record<string, string>> = {
    [Name in keyof Packages]: AsLoadableComponent<typeof import(Packages[Name])>
}
const loadableRegistry = <Packages extends Record<string, string>>(packages: Packages) => {
    return Object.fromEntries(
        Object.entries(packages).map(([name, importName]) => {
            return [key, loadable(() => import(importName))] as const;
        }),
    ) as LoadableRegistry<Packages>;
};

const DynamicComponentRegistry = someRegistry({
    Foo: 'Foo',
    Bar: 'Bar',
});

reverofevil avatar Jun 17 '21 16:06 reverofevil

This is a pretty heavy lift from the architecture side since, during the typechecking phase, we generally don't go do new expensive I/O. We could potentially allow it for modules that are already in scope (probably pretty common) but if you already know the completely list of modules you might want to look up this way, it's pretty straightforward to write a helper type somewhere with that list instead of using typeof import at the use site

RyanCavanaugh avatar Jun 17 '21 18:06 RyanCavanaugh

@RyanCavanaugh Doesn't typeof import() already do the same thing during typechecking phase?

The criteria "module has to be in scope" doesn't apply to babel, webpack and eslint, because neither of them imports modules from callee side at all.

If you let me add my subjective 2 cents, this feature is required to correctly type check at least 5 core frontend libraries, so it seems this is quite an important heavy lift to do.

reverofevil avatar Jun 17 '21 18:06 reverofevil

My use case is that my library has support for mocking dynamic imports. Essentially the api looks like this:

const {mockedImport} = await importer.import("path/to/import.js");

The api closely mimics dynamic import() syntax. So it would be nice if the returned type from this function would return the same type as you would get when actually dynamically importing it. If this were supported the signature could look like:

async importWrapper<T extends string>(url: T) : Promise<typeof import(T)>

Right now I'm resorting to using the module itself as a generic parameter. But using it like this is kind of ugly:

const {mockedImport} = await importer.import<typeof import("path/to/import.js")>("path/to/import.js")

jespertheend avatar Apr 04 '22 19:04 jespertheend

Having the same issue with vitest, this could be a nice feature!

import { vi } from "vitest";

export function importActual<T extends Parameters<typeof vi.importActual>>([path, ...args]: T): ReturnType<typeof import(`${T[0]}`)> {
    return vi.importActual(path, ...args);
}

yarinsa avatar Apr 03 '23 07:04 yarinsa

@RyanCavanaugh would it help if there was a new module primitive, such that any generic of type module is part of a first pass (before general type checking) which does all I/O. Then during type checking phase is performing a lookup on a table of all modules that were loaded during I/O phase.

It seems like something like this might be necessary if module expressions land. I imagine module expressions would be type module & Module (Module being the type for the class instance).

Usage could look like this:

function lazyImport <M extends module>(pathOrModule: M): () => typeof import(M) {
  return () => import(pathOrModule);
}

robbiespeed avatar Apr 04 '23 17:04 robbiespeed

To add one more use case, we use jest in out React application. And jest have ability to mock modules. To keep it typesafe we use the following pattern:

jest.mock(
  'path/to/module',
  (): typeof import('path/to/module') => ({  }),
);

If we could use generic import types it would make it less verbose and would remove duplication of path.

AliakseiMartsinkevich avatar May 12 '23 12:05 AliakseiMartsinkevich

same issue with jest and mocked functions. if you go the road and import the globals (instead of using ambient types), jest.SpiedFunction<T> expects a generic. if you combine this with isolateModules where you always want dynamic imports, you get types like this:

const myFnSpy = jest.SpiedFunction<typeof import("my-imported-module")["myImportedFunction"]>;

you can imagine this getting a bit verbose if you need a lot of spies in a test

DerGernTod avatar Jul 03 '23 10:07 DerGernTod

When can we expect this to land?

jamesbradlee avatar Jan 20 '24 14:01 jamesbradlee

What's the current status of this feature? Is there an expected support time?

imtaotao avatar Apr 09 '24 03:04 imtaotao

Just in case it's interesting to anyone, here's how we solved it in flow: https://github.com/facebook/flow/commit/296ed9ce7896790798b9b2fcb3ba8af7c43f7dbe

We wanted to be able to type custom import APIs (mainly for lazy-loading), for example:

lazyLoad('FooModule', FooModule => FooModule.doSomething())

And as mentioned here it's much easier if these references are easily discoverable during parsing. So we introduced "magic" prefixed string literals:

lazyLoad('m#FooModule', FooModule => FooModule.doSomething())

where 'm#FooModule' is typed as ModuleRef<typeof import('FooModule')>. The prefix is stripped out at transform time.

IMO, an ideal solution would involve a new syntactic form representing a module name. They would exist as regular strings at runtime but typecheckers could easily follow them to discover cross-module type dependencies

w0rdgamer avatar Mar 12 '25 04:03 w0rdgamer