I have a file called globals.d.ts in more projects than I’d like to admit. It started life as three lines for *.css imports, and over time it picked up *.svg, *.md, *.txt and one cursed *.sql entry that I’m fairly sure nobody has touched since it was written. Every new repo, I copy it over without reading it. It works, so I never look at it.
Then I read the TypeScript 7.1 beta announcement and the first feature on the list made me open that file and wince. Ambient module declarations can now carry import attributes. That sounds like a tiny change. It isn’t, because it quietly fixes a lie that wildcard declarations have been telling for years.
Short version for the impatient: if your project imports CSS, text or binary files, 7.1 lets you declare the type per import attribute instead of per file extension, and the compiler will complain when you forget the attribute. I haven’t moved a client project onto the beta yet, so this post is a read of the announcement plus the checklist I’m going to run, not a victory lap.
The declaration file I keep copy-pasting
Here’s roughly what lives in my globals.d.ts, and probably in yours:
declare module "*.css" {
const content: CSSStyleSheet;
export default content;
}
declare module "*.md" {
const content: string;
export default content;
}
declare module "*.txt" {
const content: string;
export default content;
}
Look at the second and third blocks. They are identical except for the extension. Every time I add a file type that should come in as a string, I paste another block. The declaration is keyed on the filename, but the thing that decides what you get at runtime is no longer the filename.
That’s the part I had stopped thinking about. Import attributes, the with { type: "text" } bit after an import, exist because a runtime can’t safely guess what a file is from its extension. The announcement uses the browser as the example: a .json file can’t just be assumed to be JSON, so you say so in the import. The MDN reference for the with clause covers the JSON case, and the text, bytes and CSS variants are in the proposals the announcement links to.
So we have a runtime that dispatches on attributes and a type declaration that dispatches on extensions. They agree when everything goes well. They disagree the moment somebody forgets the attribute, and the old declaration still hands back a perfectly typed string for an import that will blow up at runtime or give you something else entirely.
What the 7.1 beta actually adds
The new syntax puts the attribute on the module declaration itself. This is straight from the announcement:
declare module "*" with { type: "css" } {
const content: CSSStyleSheet;
export default content;
}
declare module "*" with { type: "text" } {
const content: string;
export default content;
}
declare module "*" with { type: "bytes" } {
const content: Uint8Array;
export default content;
}
Three declarations replace my pile. The pattern is a bare *, and the type depends on the attribute. After that, a file import has to spell the attribute out:
import readme from "../README.md" with { type: "text" }; // string
import theme from "./theme.css" with { type: "css" }; // CSSStyleSheet
import photo from "./photo.png" with { type: "bytes" }; // Uint8Array
Drop the with clause and the import no longer matches any ambient declaration, so you get a compile error instead of a silently wrong type. That is the entire point. A forgotten attribute becomes something the compiler catches in your editor rather than something a user catches in the browser console.
Two details from the announcement are easy to skim past. First, imports matching these declarations are not checked against files on disk. If you typo the path, TypeScript won’t tell you; your bundler or runtime will. That’s the same as today’s wildcard modules, but it’s worth remembering that a * pattern means “anything goes” for the path half of the import.
Second, the team says these declarations are not shipped in the built-in libraries, and they recommend that libraries don’t publish them either. You declare them in your own app. The reasoning is that lib.dom.d.ts and @types/node could each want to define their own, and a library that bakes one in will eventually collide with a built-in. I agree with that, and it also means my copy-the-file habit survives in a slightly smaller form. I’ll still have a globals.d.ts. It will just be shorter and more honest.
Why the extension-keyed version was always slightly wrong
I want to be fair to the old pattern. For most apps, *.css plus a bundler config worked fine for years, and I’m not going to pretend it was a disaster. Where it falls over is when two environments disagree about a file type.
The announcement gives a concrete case. A bundler may treat .css imports as CSS Modules by default, handing you an object of class names. The browser’s native behaviour with type: "css" is to hand you a CSSStyleSheet you can push into document.adoptedStyleSheets. Same extension, two different return types, selected by whether the attribute is present. A declaration keyed on *.css can only describe one of them. With the attribute in the declaration, you can have both: a CSS Modules declaration for plain .css imports and an attribute-qualified one for the stylesheet object.
I don’t use that split often. Most of my sites take one approach and stick to it. But picture a project where a component library expects constructable stylesheets while the app shell uses CSS Modules. That’s the setup where an extension-keyed declaration forces you to lie about one side. If that sounds like your codebase, this feature solves a real problem rather than a cosmetic one.
There’s also the maintenance angle. With extension patterns, every new text-like format is another block. With attributes, a new .graphql or .sql file that should arrive as a string needs no new declaration at all, only the right with { type: "text" } at the import site. Fewer declarations means fewer places for a stale one to hide, and I have at least one stale one.
The part I’d check before trusting any of it
Beta software gets a checklist, not a leap of faith. This is the order I’m going to follow on a throwaway branch of a real project, not a toy repo.
Install it next to the stable compiler so I can compare: npm install -D typescript@beta. Then run the type check once on the old declarations to see that nothing changed. If the beta flags unrelated errors, that tells me whether any problems come from my config or from the release.
Next, replace the wildcard blocks with the three attribute-qualified ones and see what breaks. I expect a pile of errors on every import that lacks with. That list is the useful output. Each error is a place where the old setup was returning a type for an import that the runtime might treat differently.
Then check the tools around the compiler, because the compiler is the easy part. Does my bundler accept with { type: "text" } and emit the right thing? Does the test runner? Does the editor’s language server pick up the new declaration form, or does it show red squiggles on valid code until it’s updated? Vite, esbuild and Node each have their own timeline for text and bytes imports, and TypeScript accepting the syntax tells me nothing about whether my build does. I’ve been bitten before by a green type check on code that the bundler later refused, so this step gets the most time.
Finally, grep the repo for every declare module "*. line and write down which ones are for files that only one environment ever consumes. Those can stay as they are. Converting everything because I can would be the kind of tidy-up that creates a Friday-evening incident.
If your project has a declare module "*.svg" for React components via a bundler plugin, leave it alone. SVG-as-component is a bundler transform, not an import attribute, and the new syntax doesn’t model it.
Source phase imports and the es2026 target
The beta has two more items I’d keep an eye on without rushing. Source phase imports add import source, which gives you a module as an inert, parsed-but-not-run object. For WebAssembly that object is a WebAssembly.Module, so you can write import source Fast from "./fast.wasm" and pass it straight to WebAssembly.instantiate. The announcement says Node already supports Wasm source imports in recent versions. For JavaScript files you can’t do much with it yet, and many tools don’t support it at all. Unless you load Wasm by hand, skip it for now.
The other is --target es2026 and --lib es2026 as stable options. The list in the announcement includes Array.fromAsync(), Error.isError(), Math.sumPrecise(), Uint8Array.fromBase64() and toHex(), plus getOrInsert() and getOrInsertComputed() on Map and WeakMap. That last pair is the one I’d use immediately. I’ve written the “get, check for undefined, set, return” dance on a Map more times than I can count, and a built-in replacement removes about four lines each time. Check your runtime’s support before bumping the target, since the lib types only promise the declarations exist, not that your Node or browser version ships the methods.
If you read the TypeScript 7 changes with the Go-based compiler in mind, the new programmatic API entry points (typescript/sync, typescript/async and friends) matter for tool authors more than for app developers. I covered a related tooling shift in my post on Vite+ 1.0, oxlint and tsgolint, and the same rule applies here: measure on your own repo before changing anything.
What to do this week
Open your globals.d.ts and count the declare module "*. blocks that return the same type under different extensions. If that number is two or more, the attribute-qualified form will probably shrink your file. Install typescript@beta on a branch, swap those blocks, and read the error list before you decide anything. If you want to see how I approach this kind of upgrade on client work, my portfolio has the write-ups.