TypeScript 6.0 `--allowArbitraryExtensions`: Typing CSS Modules, WASM Imports, and Assets Without a Hack
Stop maintaining brittle ambient declarations for CSS modules and asset imports. TypeScript 6.0's --allowArbitraryExtensions provides first-class type safety for non-JS extensions without the overhead of manual .d.ts files.
Most TypeScript projects fail at the boundary between code and assets. Developers import CSS modules, WASM binaries, or image files, and the compiler stays silent while the build breaks in production. Teams respond by scattering .d.ts files across the codebase, each one a maintenance liability that drifts out of sync with the actual asset structure. This approach fails because ambient module declarations force developers to manually replicate type information that already exists elsewhere in the toolchain.
flowchart LR
Import("import styles from './Button.module.css'")
Import --> MissingDef("no matching declaration")
MissingDef --> AnyType("implicit any")
AnyType --> RuntimeError("runtime error: styles.container undefined")
style RuntimeError stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
TypeScript 6.0's --allowArbitraryExtensions flag eliminates this entire class of problems. When enabled, the compiler looks for a .d.{ext}.ts file next to any imported file with an unknown extension. This mechanism provides type safety for CSS modules, WASM imports, and static assets without the brittle maintenance burden of global ambient declarations.
flowchart LR
Import("import styles from './Button.module.css'")
Import --> Declaration("Button.module.css.d.ts found")
Declaration --> TypedImport("styles typed as Record<string, string>")
TypedImport --> SafeAccess("styles.container autocompletes")
style SafeAccess stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- TypeScript 6.0's
--allowArbitraryExtensionseliminates the need for global ambient module declarations by allowing per-file type definitions for any file extension. - The compiler searches for a
.d.{ext}.tsdeclaration file next to the imported asset, providing type safety without modifying tsconfig patterns. - This feature matters for CSS modules, WASM imports, and static assets where build tools handle the actual transformation but TypeScript needs type information.
- Migration from ambient declarations to per-file declarations is mechanical and improves type locality without changing runtime behavior.
- The tradeoff is file count versus type accuracy: teams gain precise types at the cost of maintaining declaration files alongside assets.
Understanding --allowArbitraryExtensions in TypeScript 6.0
--allowArbitraryExtensions changes how the TypeScript compiler resolves imports for files with unknown extensions. Before this flag, importing a .css or .wasm file required a global ambient module declaration that matched the import path pattern. This created a disconnect: the type information lived in a separate location from the asset itself, making refactoring dangerous and autocomplete unreliable.
The flag instructs the compiler to look for a declaration file with the pattern {filename}.d.{extension}.ts when it encounters an import ending in an unrecognized extension. For an import like import styles from './Button.module.css', the compiler searches for Button.module.css.d.ts in the same directory. If found, that declaration file provides the type for the imported value.
flowchart TD
Import("import from './file.ext'")
Import --> CheckExt{".ext known?"}
CheckExt -->|yes| NormalResolution("resolve as JS/TS")
CheckExt -->|no, flag off| Error("compilation error")
CheckExt -->|no, flag on| SearchDecl("search for file.d.ext.ts")
SearchDecl --> Found{declaration found?}
Found -->|yes| TypedModule("typed module import")
Found -->|no| Fallback("fallback to any")
style TypedModule stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style Error stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
This matters because the declaration file now sits next to the asset, making the type information visible during code review and enforcing locality. When a developer renames Button.module.css to PrimaryButton.module.css, the corresponding .d.ts file must also move or the types break immediately. This coupling prevents the silent staleness that plagued global ambient declarations.
The performance implication is negligible. The compiler already performs file system lookups for module resolution. Adding a single additional check for .d.{ext}.ts files adds minimal overhead compared to the cost of parsing and type-checking the actual source files.
Typing CSS Modules Without Declaration Files
CSS modules present the canonical use case for --allowArbitraryExtensions. Build tools like Webpack and Vite transform CSS files into objects mapping class names to hashed strings, but TypeScript cannot infer these types from the source CSS. Before TypeScript 6.0, developers wrote global ambient declarations:
// globals.d.ts (the old approach)
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}This declaration applies to every .module.css import in the project. The problem surfaces when different CSS modules export different structures. A CSS module using :export to expose variables has a different type shape than one exporting only class names. The global declaration cannot represent this distinction.
With --allowArbitraryExtensions, each CSS module gets its own declaration file:
// Button.module.css.d.ts
declare const styles: {
container: string;
primary: string;
disabled: string;
};
export default styles;
// Form.module.css.d.ts
declare const styles: {
fieldset: string;
input: string;
error: string;
submit: string;
};
export default styles;Now the consuming code has precise autocomplete and type checking:
// Button.tsx
import styles from './Button.module.css';
function Button({ disabled }: { disabled: boolean }) {
// styles.container autocompletes
// styles.primry triggers a type error
return (
<button className={`${styles.container} ${disabled ? styles.disabled : styles.primary}`}>
Click
</button>
);
}The failure mode here is subtle but expensive. A global declaration lets you reference non-existent class names without error. The bug only appears at runtime when the class name resolves to undefined and the styles fail to apply. Per-file declarations catch this during development.
Generating these declaration files is straightforward. CSS module tooling already parses the CSS to extract class names. Plugins for Webpack, Vite, and Rollup can emit the .d.ts files during the build process. For example, typescript-plugin-css-modules generates accurate declarations based on the actual CSS content, keeping types in sync with the source.
Importing WASM Modules with Type Safety
WebAssembly modules expose functions and memory that JavaScript code calls directly. Without type information, these calls rely on runtime documentation and manual validation. A WASM module compiled from Rust might export a process_image function expecting a pointer and length, but TypeScript treats it as any.
--allowArbitraryExtensions enables type-safe WASM imports by pairing each .wasm file with a declaration:
// image_processor.d.wasm.ts
export interface ImageProcessor {
process_image(ptr: number, len: number): number;
alloc(size: number): number;
dealloc(ptr: number, len: number): void;
memory: WebAssembly.Memory;
}
declare const module: ImageProcessor;
export default module;The consuming code now has full type safety:
// processor.ts
import wasmModule from './image_processor.wasm';
async function processImage(imageData: Uint8Array): Promise<Uint8Array> {
const instance = await wasmModule;
// Type-safe allocations
const ptr = instance.alloc(imageData.length);
const view = new Uint8Array(instance.memory.buffer, ptr, imageData.length);
view.set(imageData);
// Type-safe function call
const resultPtr = instance.process_image(ptr, imageData.length);
// Cleanup
instance.dealloc(ptr, imageData.length);
return new Uint8Array(instance.memory.buffer, resultPtr);
}This pattern extends to any WASM module. The declaration file acts as a contract between the WASM binary and the TypeScript code. If the WASM module changes its exported interface, the declaration file must update or the type checker catches the mismatch.
The distinction is critical: the declaration file does not change how the WASM module loads or executes. Runtime behavior remains identical. The types only prevent incorrect usage patterns that would fail at runtime anyway.
For teams using tools like wasm-pack or Emscripten, these declaration files can be auto-generated from the source language's type system. A Rust crate compiled to WASM can emit TypeScript declarations that match the #[wasm_bindgen] exports exactly, ensuring the contract stays synchronized across compilation.
Handling Static Assets: Images, JSON, and Custom Extensions
Static assets like images and JSON files often flow through build pipelines that transform them into importable modules. Webpack's file-loader converts an image import into a URL string. Vite handles JSON imports by default. Without type information, these imports lose their structure.
For image imports, the declaration file specifies the transformed shape:
// hero-image.d.png.ts
declare const url: string;
export default url;
// logo.d.svg.ts
interface SvgModule {
default: string;
ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
}
declare const svg: SvgModule;
export default svg;This enables type-safe usage across different asset loaders:
import heroUrl from './hero-image.png';
import logo from './logo.svg';
function Hero() {
// heroUrl is typed as string
return (
<div style={{ backgroundImage: `url(${heroUrl})` }}>
{/* logo.ReactComponent is typed as a React component */}
<logo.ReactComponent className="logo" />
</div>
);
}The pattern scales to custom file types. A project using .graphql files for query definitions can define their import shape:
// UserQuery.d.graphql.ts
import { DocumentNode } from 'graphql';
declare const query: DocumentNode;
export default query;The consuming code now has autocomplete and type safety:
import userQuery from './UserQuery.graphql';
import { useQuery } from '@apollo/client';
function UserProfile({ id }: { id: string }) {
const { data } = useQuery(userQuery, { variables: { id } });
// data is typed based on the GraphQL schema
return <div>{data.user.name}</div>;
}flowchart LR
Asset("static asset")
Asset --> BuildTool("build tool transforms")
BuildTool --> Module("importable module")
Module --> DeclFile(".d.{ext}.ts provides types")
DeclFile --> TypedImport("typed import in source")
TypedImport --> Autocomplete("autocomplete and validation")
style Autocomplete stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style DeclFile stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The implication here is that type safety extends to the entire asset pipeline. Teams no longer need to choose between type safety and flexible asset handling. The build tool controls the transformation, the declaration file describes the result, and TypeScript enforces correct usage.
Comparison: allowArbitraryExtensions vs Ambient Module Declarations
Ambient module declarations solve the same problem with a different tradeoff. A global declaration covers all imports matching a pattern, reducing file count at the cost of precision. --allowArbitraryExtensions inverts this: more files, higher precision.
flowchart LR
subgraph Ambient["Ambient Module Declarations"]
GlobalDecl("declare module '*.css'")
GlobalDecl --> AllImports("applies to all CSS imports")
AllImports --> LowMaintenance("low file count")
LowMaintenance --> Imprecise("imprecise types")
end
subgraph PerFile["Per-File Declarations"]
FileDecl("Button.module.css.d.ts")
FileDecl --> SingleImport("applies to one import")
SingleImport --> HighMaintenance("higher file count")
HighMaintenance --> Precise("precise types")
end
style Imprecise stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style Precise stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The failure mode differs. Ambient declarations fail silently when the actual asset structure diverges from the declared type. A CSS module adds a new class, but the global declaration still claims it only exports a Record<string, string>. The type checker cannot warn about the mismatch because it never sees the actual CSS content.
Per-file declarations fail loudly when the declaration drifts from the asset. If a CSS module removes a class and the declaration file still exports it, consuming code that references the removed class triggers a type error. This is the desired behavior: early detection of broken contracts.
The performance difference is marginal. Both approaches require the compiler to resolve module paths and load declaration files. The cost of loading ten individual .d.css.ts files versus one global globals.d.ts file is negligible compared to the cost of type-checking the consuming code.
Migration from ambient to per-file declarations is mechanical. For each ambient module pattern, generate a declaration file for every matching asset. Tools can automate this: scan the source tree for .module.css files, extract their class names, and emit corresponding .d.ts files. The transition can happen incrementally, deprecating the global pattern once all assets have individual declarations.
The choice depends on team priorities. Projects that value low file count and accept imprecise types continue using ambient declarations. Projects that prioritize type accuracy and can tolerate additional files adopt --allowArbitraryExtensions. There is no middle ground: the precision benefit only materializes when declarations become specific to individual assets.
Migration Patterns: Moving from Manual .d.ts Files
Migrating from global ambient declarations to per-file declarations requires a systematic approach. The process has three phases: audit, generate, and validate.
The audit phase identifies all ambient module declarations in the codebase. Search for declare module statements that match file extension patterns. Common patterns include '*.css', '*.module.css', '*.svg', '*.png', '*.wasm'. Document which assets each pattern covers and what type it assigns.
The generate phase creates individual declaration files for each asset. For CSS modules, this means parsing the CSS to extract class names and generating a typed interface. For images, this means determining whether the build tool returns a URL string or a more complex object. For WASM, this means extracting the exported functions from the binary metadata.
// Automated generation example
import { parse } from 'css';
import { writeFileSync } from 'fs';
function generateCssModuleDeclaration(cssPath: string): void {
const css = readFileSync(cssPath, 'utf-8');
const ast = parse(css);
const classNames = new Set<string>();
// Extract class names from AST
// (implementation omitted for brevity)
const declaration = `declare const styles: {
${Array.from(classNames).map(name => ` ${name}: string;`).join('\n')}
};
export default styles;`;
writeFileSync(`${cssPath}.d.ts`, declaration);
}flowchart LR
GlobalDecl("global ambient declaration")
GlobalDecl --> Audit("audit all covered assets")
Audit --> Generate("generate per-file declarations")
Generate --> Remove("remove global declaration")
Remove --> Validate("validate no type errors")
style Validate stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style Generate stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The validate phase removes the global ambient declaration and runs the type checker. If the per-file declarations are accurate, the build succeeds without errors. If gaps exist, the type checker reports missing declarations for assets that were previously covered by the global pattern.
The implication here is that migration can happen incrementally. A team can generate declarations for high-value assets first, leaving the global pattern in place for less critical imports. Once confidence builds, the global pattern can be removed entirely.
The practical constraint is tooling. Without automated generation, maintaining per-file declarations becomes prohibitive. A project with hundreds of CSS modules cannot rely on manual declaration maintenance. The solution is to integrate declaration generation into the build pipeline. The same tools that transform CSS into JavaScript modules can emit TypeScript declarations as a side effect.
For teams using Vite, the vite-plugin-dts plugin automates this process. For Webpack, typescript-plugin-css-modules provides similar functionality. For custom build systems, the pattern is consistent: parse the asset, extract its interface, emit a .d.{ext}.ts file.
This approach scales to any asset type. The core insight is that the build tool already understands the asset structure. Generating TypeScript declarations is a serialization problem, not a type system problem. The declaration file simply codifies what the build tool already knows.
Frequently Asked Questions
Does --allowArbitraryExtensions work with all module resolution modes?
The flag works with both node16 and bundler resolution modes in TypeScript 6.0. It does not function with legacy node resolution because that mode predates the extension-aware resolution logic required for the feature. Teams using older resolution modes must upgrade to benefit from per-file declarations.
Can I mix ambient declarations and per-file declarations in the same project?
Yes, the compiler applies both. Ambient module declarations serve as a fallback when no per-file declaration exists. This enables incremental migration: keep the global pattern for low-priority assets while generating per-file declarations for critical modules. Once all assets have specific declarations, remove the ambient fallback.
How do I generate declaration files for assets that change frequently?
Integrate declaration generation into the build pipeline so the declarations update automatically when assets change. Most build tools support plugins that emit TypeScript declarations as a side effect of processing the asset. For CSS modules, the plugin parses the CSS and generates a .d.ts file. For images, the plugin infers the type from the loader configuration. This automation prevents declaration drift.
What happens if I import an unknown extension without a declaration file?
The compiler falls back to any if --allowArbitraryExtensions is enabled but no matching declaration file exists. This preserves backward compatibility: existing code continues to compile, but loses type safety. To enforce strict typing, enable noImplicitAny alongside --allowArbitraryExtensions so the compiler reports an error when a declaration is missing.
Does this feature affect runtime behavior or bundle size?
No. The declaration files are compile-time artifacts only. They do not appear in the transpiled JavaScript or the final bundle. The feature only changes how TypeScript validates imports during type checking. Runtime behavior, module loading, and bundle size remain unchanged.
Conclusion: When to Use This Feature in Production
--allowArbitraryExtensions eliminates a maintenance burden that teams tolerated for years: the disconnect between asset structure and type information. The feature is production-ready when three conditions hold. First, the project already uses a build tool that transforms non-JS assets into importable modules. Second, the team values precise autocomplete and type checking over minimizing file count. Third, declaration generation can be automated so the type information stays synchronized with the assets.
The alternative is to continue using global ambient module declarations and accept their limitations. For small projects or teams that rarely modify asset interfaces, this remains viable. For projects where assets change frequently or where type safety prevents production bugs, the precision of per-file declarations justifies the tooling investment.
That covers the essential patterns for typing CSS modules, WASM imports, and static assets in TypeScript 6.0. Apply these in production and the difference will be immediate: fewer runtime errors, better autocomplete, and type information that actually reflects your build pipeline.
For deeper exploration of TypeScript's type system, see 10 TypeScript Utility Types for Bulletproof Code. Teams working with large-scale refactoring can leverage AI-Powered TypeScript Refactoring Workflows to automate declaration file generation. For context on managing complex TypeScript projects at scale, 2 Million Token Context Windows for Real Web Apps demonstrates the architectural patterns that make type safety feasible.