Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ambient Module Declarations for Import Attributes (formerly known as Import Assertions) #46135

Open
5 tasks done
haoqunjiang opened this issue Sep 29, 2021 · 7 comments
Open
5 tasks done
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@haoqunjiang
Copy link

Suggestion

πŸ” Search Terms

import assertions, ambient module

βœ… 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.

⭐ Suggestion

Following the suggestion at #40694 (comment)
Allow projects to declare ambient modules based on the import assertions. Such as:

declare module '*' assert {type: 'css'} {
  const stylesheet: CSSStyleSheet;
  export default stylesheet;
}

πŸ“ƒ Motivating Example

Currently frontend build tools use file extensions and URL query strings to support all kinds of custom modules that are eventually compiled into JavaScript.

For example, in Vite (webpack supports these features in a similar way), we support JS modules importing .module.css files as CSS modules, and adding ?url postfixes to the imported path means we are to import the asset's real URL after bundling.

That is:

// get the exported classes from a CSS module
import classes from './example.module.css'

// Explicitly load assets as URL
import assetAsURL from './asset.js?url'

But:

  1. The extension approach may collide with new browser native features, such as the native CSS modules. So it's better to have a way to differentiate them. Import assertions could help in this case.
  2. The query string approach is not a standard.
    • It alters the standard semantics of an import statement
    • Different tools varies on their choices of queries
    • Modules with such syntaxes won't be able to run separately without the specific bundler
    • So, we hope to migrate away from this approach and base the feature upon a standard. Import assertions seems to be the way to go.

One thing that blocks us from doing so is the ability to declare ambient modules based on import assertions in TypeScript.

We can easily have type definitions for files based on their file extensions and query strings. In vite we have: https:/vitejs/vite/blob/b690810f555549e9eed4b03600b27fe7649d6b07/packages/vite/client.d.ts

But currently, I don't see a way to migrate these type definitions when we move to import assertions.

πŸ’» Use Cases

  1. The abovementioned frontend build tools.
  2. Type definitions for browsers' built-in CSS & JSON modules support. We can have declare module '*' assert {type: 'json'} and declare module '*' assert {type: 'css'} in the DOM lib.
@DanielRosenwasser DanielRosenwasser added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Sep 30, 2021
@justinfagnani
Copy link

Thanks for opening this @sodatea!

I think the important point with import assertions is that for the standard assertions (type: 'css' and type: 'json') it's the assertions themselves and nothing about the specifier that determine the type of module. (well, technically it's the MIME type, but if the MIME type and assertion disagree the module will fail, so we can ignore that case)

So for native module types it only makes sense to declare ambient modules by assertion, never by name.

@NuckChorris
Copy link

NuckChorris commented Nov 9, 2021

Just wanted to mention that this would also be a boon for stuff like imagetools which provide complex types currently via query params, but could potentially use assertions instead (and have much better syntax and types as a result!)

@jakearchibald
Copy link

jakearchibald commented Nov 22, 2023

Thoughts on this, following discussion in #56359

Import attributes are a great way to tell a bundler how to interpret/preprocess a file being imported. Eg "load this as plain text", "load this as an ImageBitmap", "bundle this file and give me a URL to it".

The same file may be imported using different types:

import anImage from './icon.png' with { type: 'rollup-ImageBitmap' };
import aURL from './icon.png' with { type: 'rollup-bundled-url' };
import aBase64URL from './icon.png' with { type: 'rollup-base64-url' };

This is much better than the current pattern bundlers/TypeScript uses, where anything ending .png is assumed to be consumed in a particular way, or uses some kind of module prefix/suffix.

Some types, such as { type: 'json' } and { type: 'css' }, may have built-in support on some platforms. It should be possible to use these in conjunction with custom bundler types.

In a case like:

import aDictionary from './styles.css' with { type: 'rollup-css-module' };
import source from './styles.css' with { type: 'rollup-text' };

The build tool may wish to create a definition file specific for styles.css when it's loaded with { type: 'rollup-css-module' }, so it exposes CSS module entries specific to that file, probably by generating a styles.css.d.ts of sorts.


I don't mind how the use-cases are solved, but here are some loosely-held thoughts/ideas.

I know there are two types of .d.ts file, but I cannot remember their official names, and can't find a page in the TypeScript docs that distinguishes between the two. So here's the definitions I'm using for this post:

  • sidecar definition file - this is a .d.ts file that describes the exports of a particular file. This is the mode that TypeScript uses when a .d.ts contains an import or export statement.
  • ambient definition file - this is a .d.ts file that applies across the whole project. This is the mode that TypeScript uses when a .d.ts does not contain an import or export statement.

Taking from #56359, I assume this syntax is for ambient definition files:

declare module "*" with { type: "css" } {
    declare const _default: CSSStyleSheet;
    export default _default;
}

Open question, would it be valid to use types in the above? For example:

declare module "*" with { type: "css" | "scss" } {
    declare const _default: CSSStyleSheet;
    export default _default;
}

And I assume this syntax is for use in sidecar definition files:

declare with { type: "css" } {
  export const header: string;
  export const footer: string;
  export const button: string;
}

It feels like exports outside one of these blocks should only apply to imports without attributes.

I know this is hand-wavy and incomplete, but the process of picking a definition for an import with attributes could be something like:

  1. If there's a sidecar definition file with a matching declare with block, use those types.
  2. Otherwise, if there's an ambient definition file with a matching declare … with, use those types.
  3. Otherwise, if the attributes are of a known platform type (json, css), use those types.
  4. Otherwise, there are no types for this import.

@justinfagnani
Copy link

For non-ambient declarations I think we'll also need a way to specify that there's no support for an import with no type attribute, as is true for CSS and JSON.

@jakearchibald
Copy link

declare with undefined never? 🫀

@fatcerberus
Copy link

I'd just like to point out that "sidecar" vs. "ambient" .d.ts is not a distinction that exists per se - as far as TS is concerned, a .d.ts is always a companion to a corresponding JavaScript file. What you're calling an "ambient" declaration simply describes a .js file that runs as a script (i.e. in global scope, like classic <script> tags) rather than as a module.

@justinfagnani
Copy link

#56359 has a number of questions from the design meeting on this issue.

I'll attempt to give my personal responses on them here to keep things collocated:

Declaring modules with attributes

  • How do you convince TypeScript of this?
  • Maybe something like:
    declare module "*" with { type: "css" } {
        declare const _default: CSSStyleSheet;
        export default _default;
    }

πŸ‘ (even though this wasn't a question)

Allowing module declarations to key off of import attributes is in general a great feature that will work with all kinds of tool-specific attributes. I'd assume that lib.dom.d.ts would include a declaration like the above.

Handling multiple matching module declarations

  • How does this work with merging? Multiple declarations? Fallbacks?

Presumably there can already be multiple matching module declarations with path wildcards. How does that work today? Merging?

For CSS and JSON, I think there are likely three common cases where merging might happen:

  1. A library includes a declaration for {type: 'json'} or {type: 'css'}, and then a new TypeScript version also includes those in lib.dom.d.ts.

    Those should be compatible. I'd personally want some kind of warning if they weren't compatible.

  2. A tool adds a new feature, like named exports for CSS, on top of the standard type.

    Ideally the tool would use a new attribute or new type value, but I'm sure we'll see tools that don't. In this case maybe they'll generate a foo.d.css.ts (with Add --allowArbitraryExtensions, a flag for allowing arbitrary extensions on import pathsΒ #51435) declaration file so that it has the specific named exports for the specific CSS file. It would be most accurate if this declaration file could somehow say if the file must be imported with the {type: 'css'} attribute, and then that declaration was merged with the one in lib.dom.d.ts.

  3. A tool or library includes a module declaration for a non-standard import, like declare module "*.css" ... which also matches a standard CSS/JSON module declaration keyed off the import attribute.

    This is the trickiest case because these declarations are most likely not compatible. I would want the declaration keyed off the import attribute to take precedence instead of merging.

    Maybe what we can do here say that for a declaration to be merged with other declarations that include import attributes, that the declaration has to have an attributes declaration, even if it's empty. ie:

    declare module "*.css" with {} { ... }

    would match all *.css imports regardless of the type attribute.

Are import attributed even needed for module declarations?

  • Why can't you get away with just always using a *.css pattern?

    • Bundlers and browsers decide on different behavior - or rather, bundlers decide differently.

The bundler vs browser issue is one reason, but I hesitate to call one behavior "bundler" because there are now bundler plugins that support the standard behavior.

The real core reason is that import attributes are the way that browsers determine/verify the module type - they don't do so based on file name - so the type system should work the same way.

  • Stepping back, pattern ambient modules really mask over everything. Kind of heavy-handed. You do want TypeScript to resolve the .css file on disk and validate that it's there, right? Usually?

You absolutely do want the files to be resolved on disk. One of the problems with today's wildcard declarations is that jump-to-definition goes to the declaration and not the file. That might be useful sometimes when trying to debug your types, but usually you want to open the .css or .json file in your editor.

Path-less declarations

What about something like

declare with { type: "css" } {
    declare const _default: CSSStyleSheet;
    export default _default;
}
  • Doesn't permit arbitrary paths for *.css imports, but does provide a well-understood shape.

Seems fine, though is this really different than declare module "*" with?

Resolution

  • Doesn't this need a special resolution mode?

No. At least for the standard module types (json and css) the specifier should resolve exactly the same as without a type attribute.

Those files might not be in the same root tree as the .ts files, but this can be handled with multiple root dirs. Even better, .json and .css files should be emitted if they are in the root dir and references as an import.

There could conceivably be tools that alter specifiers of imports with certain attributes. Those will just have to use their own declarations?

Emit

  • How does this affect emit?

    • We don't emit .json files.

    • So we wouldn't copy over .css files from source?

      • No?
      • That's weird.
      • Mismatch between runtime and build time.
    • What about .d.css.ts generation?

  • How do .json imports work at all today?

I think these files are part of the source tree and should be copied over. Projects shouldn't need to add extra tools to get a project that only uses standard module types to build and run.

  • Do people rarely do it?

  • If it's not a problem for JSON, is it not a problem for CSS?

  • Wait, we do copy JSON to outputs?

    • We coulda sworn we didn't...
    • Well we re-emit the JSON, it's not a naive copy.
    • We just don't overwrite the JSON if there's no outDir.

I haven't ever seen JSON files emitted. Is this true of plain tsc usage?


I hope that helps. I have a bias towards standard module types and semantics just working, but I think that leads to mostly obvious answers to the questions here - differentiating standard from non-standard behavior being possibly the trickiest question.

@haoqunjiang haoqunjiang changed the title Ambient Module Declarations for Import Assertions Ambient Module Declarations for Import Attributes (formerly known as Import Assertions) Sep 2, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

6 participants