Typing CSS Modules and Other Non-JavaScript Imports (declare module '*.css')
Introduction
Many TypeScript projects import non-JavaScript assets: CSS modules, images, JSON, WebAssembly, and more. By default, TypeScript treats these imports as "any", which loses type safety and editor autocompletion. For intermediate developers building scalable front-end apps or libraries, adding accurate type definitions for these imports reduces runtime surprises, improves tooling, and makes refactors safer.
This article teaches practical, maintainable patterns for typing CSS Modules and other non-JavaScript imports in TypeScript. You will learn how to create ambient module declarations like declare module '*.css', generate typed definitions automatically from build output, and integrate these types into bundlers and CI pipelines. We'll cover classic CSS modules, CSS-in-JS artifacts, images, fonts, JSON types, and WebAssembly bindings. You'll get ready-to-use examples, step-by-step instructions, and troubleshooting tips for common bundlers and toolchains.
Throughout the article, you will also see how typing integrates with build tools and performance optimizations. For example, when you use esbuild or swc to speed up compilation, you must ensure type definitions are consistent with emitted artifacts; see our guide on using esbuild or swc for faster TypeScript compilation for details on toolchain trade-offs. We'll discuss how linters and formatters should treat generated declaration files and how to include them in monorepos with shared typings; refer to Managing Types in a Monorepo with TypeScript if you centralize types.
What you'll learn:
- How to author ambient module declarations like
declare module '*.css'and make them precise - How to generate and validate typed declarations for CSS modules and assets
- How to wire types into different bundlers and toolchains
- How to avoid common pitfalls and improve DX with editors and CI
By the end, you will have a robust, production-ready approach to typing non-JS imports across apps, libraries, and monorepos.
Background & Context
TypeScript's module resolution expects JavaScript/TypeScript modules. When you import other file types, like import styles from './App.module.css', TS needs a declaration to know the module shape. Without one, TypeScript falls back to any for the import, disabling type checking and autocompletion. Ambient module declarations are the long-standing mechanism: a simple declare module '*.css' tells TypeScript such imports exist, and you can specify the exported shape (e.g., Record<string, string>).
However, a one-size-fits-all any or Record<string, string> is often insufficient. CSS Modules can have a defined set of class names (often derivable at build time), and typed definitions enable IDE completion and stricter refactors. Similarly, images or JSON may have stable shapes that should be validated at compile time. Integrating typed declarations into your build and validation steps also helps when optimizing for performance in TypeScript compilation, a topic explored in Performance Considerations: TypeScript Compilation Speed to keep developer feedback loops tight.
When using bundlers like Rollup or Webpack, and when integrating tools like Prettier and ESLint, you want type generation to be interoperable with the rest of your toolchain; see guides on Using Rollup with TypeScript: An Intermediate Guide and Using Webpack with TypeScript: ts-loader and awesome-typescript-loader Deep Dive for bundler specifics.
Key Takeaways
- Ambient declarations are the entry point:
declare module '*.css'can be simple or precise. - Prefer generated
.d.tsfiles for exact typings of CSS Module class names where possible. - Integrate type generation into build or prepublish steps so types stay in sync with assets.
- Use bundler-aware plugins and ensure TypeScript compiler flags don't hide errors.
- Centralize and share generated typings in monorepos for consistency.
- Validate output with tests or build-time checks to avoid drift.
Prerequisites & Setup
Before proceeding, ensure you have:
- Node.js 14+ and a package manager (npm, pnpm, yarn)
- TypeScript installed (>=4.x) and a tsconfig.json
- A bundler or build system like Webpack, Rollup, esbuild, or Vite
- Basic knowledge of ambient module declarations and TypeScript configuration
If you use build accelerators, review the trade-offs in using esbuild or swc for faster TypeScript compilation. If you're in a monorepo, plan where to surface generated typings; the guide on Managing Types in a Monorepo with TypeScript will be helpful.
Now let’s dive into practical sections showing patterns, examples, and troubleshooting.
Main Tutorial Sections
1) Basic Ambient Declaration for CSS (the starting point)
Create a global declaration file, e.g., src/types/global.d.ts, and add a minimal module declaration:
declare module '*.css' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}This approach gives type checking for property access like styles['some-class'], but it still allows typos at compile time because properties are not known. Use this as a safe default to avoid compiler errors, then iterate toward stricter typing.
2) Strongly-typed CSS Modules via Generated .d.ts Files
To get exact class names, generate .d.ts files during build. Tools such as typed-css-modules or PostCSS plugins can emit a file next to the CSS:
Example using a command-line generator (pseudo):
npx typed-css-modules src/components --outDir src/components
A generated Button.module.css.d.ts might look like:
declare const classes: {
readonly 'btn': string;
readonly 'btnPrimary': string;
};
export default classes;Now import styles from './Button.module.css' offers autocompletion and prevents accessing non-existent classes. Make this generation part of your build or prepublish step so types stay aligned with CSS.
3) Integration with Build Tools: Rollup and Webpack
Bundlers often transform CSS differently. With Rollup, use plugins like rollup-plugin-postcss and ensure the plugin emits or keeps class names consistent with the generated types. See our Rollup guide for patterns on integrating TypeScript and bundlers: Using Rollup with TypeScript: An Intermediate Guide.
With Webpack and css-loader configured for modules, class names can be hashed. Generate types from the source files, not the hashed output, or configure css-loader with localIdentName predictable patterns during development so types remain useful. When using ts-loader or advanced loaders, follow compatibility practices from Using Webpack with TypeScript: ts-loader and awesome-typescript-loader Deep Dive.
4) Using Declaration Merging for Multiple Asset Types
You can combine declarations in one file for different asset types:
// src/types/assets.d.ts
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.svg' {
import * as React from 'react';
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}This file tells TS how to treat images and also provides a named ReactComponent export for svgs when using appropriate bundler plugin. This pattern centralizes non-JS imports and is simple to maintain.
5) Type-Safe JSON and Other Asset Imports
JSON imports can be typed more strictly. For example, if settings.json has a known shape:
// src/types/json.d.ts
declare module '*.json' {
const value: any; // fallback
export default value;
}But prefer explicit typing when importing: import settings from './settings.json' assert { type: 'json' } (in environments that support it) and then cast or validate: const settings = (await import('./settings.json')).default as AppSettings; Use runtime validation (zod, io-ts) if shape must be guaranteed at runtime.
6) WebAssembly and Nontrivial Binary Imports
For projects using WebAssembly, typing the import surface helps. See our deeper WebAssembly-TS typing article for interop patterns: Integrating WebAssembly with TypeScript: Typing Imports and Exports. Typical pattern:
declare module '*.wasm' {
const wasmModule: WebAssembly.Module;
export default wasmModule;
}More advanced workflows use Emscripten or wasm-bindgen which provide their own type artifacts; prefer auto-generated bindings when available and include them in your package.
7) Automating Type Generation in CI and Prepublish
Add a step in CI to regenerate and check .d.ts files. Example pipeline snippet:
# generate typings npx typed-css-modules 'src/**/*.module.css' --outDir src # run typecheck pnpm -w tsc -p tsconfig.build.json --noEmit
Fail the build if generated types differ from committed files. This prevents drift between CSS and typings. If you use fast transformers like esbuild, make sure generation runs outside the transform and before typecheck; refer to build performance constraints in Performance Considerations: TypeScript Compilation Speed.
8) Editor Experience and Auto-Completion
Place generated .d.ts files next to the source files or in a central types folder referenced by typeRoots in tsconfig. Example tsconfig snippet:
{
"compilerOptions": {
"typeRoots": ["./src/types", "./node_modules/@types"]
}
}This gives IDEs immediate access to exact prop names for CSS classes and images. Consider a dev-only localIdentName for readable class names so autocompletion is meaningful during development.
9) Typing CSS-in-JS and Styled Systems
CSS-in-JS solutions (Emotion, styled-components) expose typed APIs. If you import CSS-like files from a design system, create interfaces for tokens and variants. Example for a tokens file:
// tokens.d.ts
declare module '*.tokens.css' {
export const tokens: Record<string, string>;
export default tokens;
}Prefer TypeScript modules for tokens and theme objects so you can gain type-safety without ambient declarations. For runtime theme validation, integrate with dedicated validation libraries.
10) Linting, Formatting, and Tooling Integration
Prevent intermittent commits of autogenerated files by adding them to .gitignore or by committing them and validating in CI. When using Prettier or ESLint, ensure generated .d.ts files conform to your rules or exclude them. See guidance on integrating formatting and linting with TypeScript in Integrating Prettier with TypeScript — Specific Config and Integrating ESLint with TypeScript Projects (Specific Rules).
If your formatter changes generated files, prefer running format after generation in CI to keep diffs clean.
Advanced Techniques
When you need expert-level reliability, consider these strategies:
- Schema-driven generation: Source your allowed class names from SASS variables, PostCSS tokens, or a design token JSON, then generate both CSS and
.d.tsartifacts from the same source so they never diverge. - Type-level guarantees: For libraries, export types for consumers, e.g.,
export type ButtonClass = keyof typeof import('./Button.module.css').defaultto allow compile-time checks against only valid class names. - Runtime validation: Combine static types with runtime checks using zod or io-ts to verify JSON or external assets at app startup and fail fast if a shape doesn't match expectations.
- Build-time enforcement: Add a step to fail the build if any
styles['unknown']access is found by running custom eslint rules or static analysis that cross-references generated.d.tskeys.
Also consider the impact of advanced compiler flags. Flags such as noUncheckedIndexedAccess can surface issues when indexing typed CSS modules; see more on safe indexing with our article Safer Indexing in TypeScript with noUncheckedIndexedAccess.
Best Practices & Common Pitfalls
Dos:
- Generate precise
.d.tsfiles for CSS modules when possible. - Integrate generation into CI to avoid drift.
- Keep declarations centralized and documented in a
src/typesfolder. - Use runtime validation for external data and asset metadata.
Don'ts:
- Don’t assume
Record<string, string>is sufficient for large codebases; it invites silent typos. - Don’t rely on hashed class names in production to be human-readable for types; generate types from source.
- Don’t forget to exclude generated files from aggressive formatters or linters if they conflict.
Troubleshooting common issues:
- "Editor shows no autocompletion for classes": ensure
.d.tsfiles are visible intypeRootsand not excluded byexcludein tsconfig. - "Types out of sync" between CSS and
.d.ts: run the generator locally, commit the results, and add a CI validation step. - "Bundler emits hashed class names": generate typings from the original CSS files, not the emitted assets, or configure a stable development naming scheme.
For packaging libraries, ensure you publish .d.ts files along with compiled code so consumers benefit from types. The process of contributing to compiler or ecosystem tools is a different skillset and helps when you need to modify transformers or plugins; if you aim to extend TypeScript itself or plugin ecosystems, see Contributing to the TypeScript Compiler: A Practical Guide and Contributing to DefinitelyTyped: A Practical Guide for Intermediate Developers.
Real-World Applications
- Component Libraries: Publish components with exact CSS module typings so consumers get autocompletion for class names and theme tokens. This reduces integration friction.
- Design Systems: Keep token definitions in a single JSON schema and generate CSS, tokens TypeScript modules, and
.d.tsfiles from that schema to maintain single-source-of-truth consistency. - Monorepos: Centralize asset typings in a types package consumed across packages; see Managing Types in a Monorepo with TypeScript for patterns on sharing.
- Performance-sensitive apps: Pair faster TypeScript transforms with type-generation steps and review compiler flags in Advanced TypeScript Compiler Flags and Their Impact to keep typechecking fast and correct.
These use cases illustrate why precise typings matter beyond DX—they affect release stability, integration speed, and maintenance cost.
Conclusion & Next Steps
Typing CSS Modules and other non-JS imports makes your TypeScript codebase safer and more maintainable. Start with simple ambient declarations, then step up to generated .d.ts files and CI-validated workflows. Integrate with your bundler and formatting tools, and centralize types in monorepo setups when needed.
Next steps: add a generator to your dev scripts, update tsconfig typeRoots, and enable CI checks. Explore linked resources on bundlers and tooling to align your generation approach with your build pipeline.
Enhanced FAQ
Q1: "What is the simplest way to avoid TypeScript errors when importing CSS files?"
A1: Add a minimal ambient declaration such as declare module '*.css' { const classes: { [key: string]: string }; export default classes; } in a .d.ts file that TypeScript loads. This avoids errors but doesn’t provide exact autocompletion.
Q2: "How do I get exact class name autocompletion for CSS Modules?"
A2: Use a generator that creates .d.ts files next to your .module.css files. Tools like typed-css-modules or PostCSS plugins can extract class names and emit TypeScript declarations. Commit generated files or regenerate them in CI.
Q3: "Where should I put generated .d.ts files?"
A3: Common patterns: place them next to the source file (e.g., Button.module.css.d.ts) so imports resolve naturally, or store them in a src/types folder and configure typeRoots in tsconfig. Putting them next to source often gives the best editor UX.
Q4: "Should generated files be committed?" A4: Yes for projects where consumers expect types (libraries) or when type generation is nontrivial and you want deterministic builds. If they are expensive to generate, you can regenerate in CI but ensure checks prevent drift.
Q5: "How do hashed class names affect typings?"
A5: Hashing is a runtime bundler concern. Generate typings from source class names, not hashed outputs. During development, consider a readable localIdentName to make debugging easier. Ensure generator reads source files directly.
Q6: "How do I type SVG imports that export a React component?" A6: Add a module declaration that exports both a default string and a React component named export, like:
declare module '*.svg' {
import * as React from 'react';
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}Then use import { ReactComponent as Logo } from './logo.svg'; in your JSX.
Q7: "What about images and fonts?"
A7: For images and fonts, a simple declaration declare module '*.png' { const src: string; export default src; } is usually enough because consumers treat them as URLs. For stricter checks, create asset manifests and type them.
Q8: "How should I handle JSON imports?"
A8: If JSON is stable and shared, create a TypeScript module (.ts) exporting typed data, or use runtime validation before casting. Prefer explicit interfaces for important config JSON and validate in tests or at runtime.
Q9: "How do these patterns change in monorepos?"
A9: Centralize types in a shared package or typeRoots, ensure build steps in each package generate or depend on the same typings artifact, and add CI validation across packages. For patterns and sharing strategies, check Managing Types in a Monorepo with TypeScript.
Q10: "What performance impacts should I consider?" A10: Type generation is usually cheap, but TypeScript typechecking can be heavy. Use faster transformers like esbuild for transforms while preserving typecheck steps separately; consult Performance Considerations: TypeScript Compilation Speed and review compiler flags in Advanced TypeScript Compiler Flags and Their Impact to balance speed and safety. When using linters and formatters, exclude or harmonize generated files to keep the IDE snappy.
If you want concrete starter scripts or CI YAML examples for a specific bundler (Webpack, Rollup, or esbuild), tell me which one you use and I will provide ready-to-copy configurations and generator commands. Also consider reading our guide on Building Command-Line Tools with TypeScript: An Intermediate Guide if you plan to author your own generator or CLI for typing assets.
