typed-css-imports
v0.2.0
Published
Typed CSS and Sass module imports with plain tsc: `styles.buttn` is a compile error. A TypeScript 7.1 content mapper.
Maintainers
Readme

/* button.module.css */
.button { color: white; }
.button-primary { background: royalblue; }import styles from "./button.module.css";
styles.buttonPrimary; // string
styles.buttn;
// error TS2551: Property 'buttn' does not exist on type '{ readonly button: string; ... }'. Did you mean 'button'?- Real types, not
{ [key: string]: string }. Every class, id,@keyframesand@valuename the bundler exports, and nothing else. Typos, removed classes and:globalnames are errors. - Same names as your bundler. Names come from postcss-modules, the plugin Vite uses, including
localsConvention(camelCase,dashes, …). - No codegen, no
.d.tsfiles to commit. Built on TypeScript 7.1 content mappers (7.1 is in beta). Add a class in the CSS file and it autocompletes in your component immediately, even before you save. - Go to definition, find references and rename.
styles.buttonjumps to.buttonin the CSS, references include the rule, and renaming the class from either file edits the CSS and every component. See Editor features. - Overrides the generic stubs. The
declare module "*.module.css"declarations fromvite/clientor Next.js stay in place; the mapped types take precedence. - Sass too.
.module.scssand.module.sassare compiled with your project's Sass first, as your bundler does, so classes generated by mixins, loops and partials are typed. - No runtime. Your bundler already loads CSS modules. This package only provides types.
Setup
Requires TypeScript 7.1 and Node >= 22.12. Until 7.1 is released, install the beta:
pnpm add -D typed-css-imports [email protected]tsconfig.json:
{
"compilerOptions": {
"allowArbitraryExtensions": true
},
"contentMappers": [
{
"package": "typed-css-imports",
"extensions": [".module.css"], // add ".module.scss", ".module.sass" for Sass modules
"options": { "localsConvention": "camelCase" } // optional: match your bundler's setting
}
]
}Run tsc with --runExternalCode
Content mappers run package code during type-checking, so TypeScript only starts them with an explicit opt-in:
tsc --noEmit --runExternalCodeEditors need the same opt-in from their language-server client; see Editor setup.
Can't use --runExternalCode or TypeScript 7.1? Generate files instead
typed-css-imports generate writes the same types to declaration files next to each module:
typed-css-imports generate [--locals-convention camelCase] src/**/*.module.css # writes x.module.d.css.ts
typed-css-imports generate --load-path src/styles src/**/*.module.scss # writes x.module.d.scss.ts
typed-css-imports generate --check src/**/*.module.css # exit 1 if any are staleWith allowArbitraryExtensions, tsc picks up button.module.d.css.ts for import styles from "./button.module.css" and no content mapper is needed.
Check it's working
If the mapper isn't running, imports don't fail: they fall back to the { [key: string]: string } stub from vite/client or Next.js, and typos pass silently. Add one file that turns that into an error:
// src/css-modules.check.ts
import styles from "./Button.module.css"; // any CSS module in your project
// @ts-expect-error typed-css-imports is not running if this line is not an error
styles.thisClassDoesNotExist;With the mapper running, this file type-checks. If the mapper isn't running (for example an editor on a TypeScript version without content mappers, or a tsconfig.json that lost the contentMappers entry), it reports TS2578 "Unused '@ts-expect-error' directive", in CI and in the editor. examples/vite-react includes one.
Sass modules
Add .module.scss (and .module.sass) to extensions. The modules are compiled with the sass or sass-embedded package installed in your project (the one your bundler uses), so install it if it isn't already there. If your bundler config has Sass options that affect the output, mirror them under options.sass:
"options": {
"sass": {
"loadPaths": ["src/styles"], // relative to tsconfig.json; Vite: css.preprocessorOptions.scss.loadPaths
"additionalData": "@use \"tokens\" as *;\n" // Vite: css.preprocessorOptions.scss.additionalData (string form)
}
}Editor setup
The TypeScript 7.1 language server (tsc --lsp) starts content mappers only when the client sends initializationOptions: { runExternalCode: true }. Without it, CSS module imports report TS2307, or with vite/client types silently fall back to { [key: string]: string }.
How soon types update depends on what the editor sends the server:
- As you type: if the editor also attaches the TypeScript server to
.module.css(or.module.scss) buffers, each edit sends the unsaved CSS. The server re-runs the mapper on that text and asks the editor to refresh diagnostics, so errors in components appear before the CSS file is saved. This is what the demo above shows. - On save: otherwise the server sees the CSS file when it changes on disk.
tsc --noEmit --runExternalCode on the command line always reads saved files.
Editor features
Besides errors and completion, the mapper tells the language server where each name is declared in the module:
| | .button, #main, @keyframes spin | "button-primary", buttonPrimary (camelCase) | Sass &-title, .tone-#{$t} |
|---|---|---|---|
| Go to definition | The name in the CSS | The class it comes from | The selector that generates it |
| Find references (from either file) | Yes | Yes | Yes |
| Rename | From the component or the CSS, edits both | Not supported (see Limits) | Not supported |
Names that come from another file (a Sass partial or a mixin defined elsewhere) are typed, but have no definition in the module.
VS Code
Install the TypeScript 7 extension (
TypeScriptTeam.native-preview).Point
js/ts.tsdk.pathat the project's TypeScript 7.1 package, in.vscode/settings.json(relative to the workspace folder) or in user settings:{ "js/ts.tsdk.path": "node_modules/typescript" }Run TypeScript: Select TypeScript Version... and choose Use Workspace Version. The extension starts with the TypeScript 7.0 it bundles and doesn't prompt to switch by default.
Trust the workspace. In Restricted Mode the extension doesn't run at all, so nothing is type-checked.
The extension enables content mappers by default (js/ts.contentMappers.enabled) and attaches to open .module.css and .module.scss files, so edits update errors and completions in components before you save.
On the bundled 7.0 there are no errors: imports silently fall back to vite/client's { [key: string]: string }. The check file catches this, as long as the workspace is trusted.
Tested with VS Code 1.135 and extension version 1.0.1, with CSS and Sass modules.
LazyVim
In lua/config/options.lua:
vim.g.lazyvim_ts_lsp = "tsc"In lua/plugins/typescript.lua:
return {
"neovim/nvim-lspconfig",
opts = function(_, opts)
-- cmd and root_dir share a cache of which tsc binary to run, so take both from the same copy of lspconfig's config.
local tsc = dofile(vim.api.nvim_get_runtime_file("lsp/tsc.lua", false)[1])
opts.servers.tsc = vim.tbl_deep_extend("force", opts.servers.tsc or {}, {
mason = false, -- run the project's node_modules/.bin/tsc
init_options = { runExternalCode = true },
-- CSS and Sass modules are attached so unsaved edits reach the server. Other stylesheets have no project and are skipped.
filetypes = { "javascript", "javascriptreact", "typescript", "typescriptreact", "css", "scss", "sass" },
cmd = tsc.cmd,
root_dir = function(bufnr, on_dir)
local ft, name = vim.bo[bufnr].filetype, vim.api.nvim_buf_get_name(bufnr)
if not vim.list_contains({ "css", "scss", "sass" }, ft) or name:match("%.module%.s?[ac]ss$") then
tsc.root_dir(bufnr, on_dir)
end
end,
})
end,
}Tested with Neovim 0.12, LazyVim 16 and the lang.typescript extra. Neovim 0.11 doesn't handle workspace/diagnostic/refresh, so components update only when they are edited.
Example
examples/vite-react is a Vite + React app with vite/client types and localsConvention: "camelCaseOnly" in both vite.config.ts and tsconfig.json. pnpm --filter example-vite-react run parity loads every CSS and Sass module (the example's and the test fixtures') through Vite and checks that the names Vite exports at runtime match the types exactly. examples/next-parity does the same with Next.js, building with webpack and with Turbopack.
What's exported
| CSS | exported name |
|---|---|
| .button { }, .a:is(.b, .c), &.active (nesting) | button, a, b, c, active |
| #main { } | main |
| @keyframes spin { } | spin |
| @value brand: #0a7; | brand |
| .sm\:p-4 { } | sm:p-4 |
| :global(.x), :global .x | (not exported) |
| class names in comments, strings, url() | (not exported) |
| .button-primary with localsConvention: "camelCase" | button-primary and buttonPrimary |
| Sass: &-title nesting, @each $t in a, b { .is-#{$t} {} }, classes from @used partials and mixins | card-title, is-a, is-b, … |
| Sass: %placeholder, $variables, empty rules | (not exported, as in the compiled CSS) |
composes: x from "./other.css" is supported. Other files are never read during type-checking, because composes doesn't change which names a module exports.
CSS and Sass syntax errors are reported at their line and column in the module. Errors in a @used file are reported at the top of the module, with the file and line in the message.
Compared with other tools
| | typescript-plugin-css-modules | typed-css-modules, happy-css-modules | css-modules-kit | typed-css-imports |
|---|---|---|---|---|
| How | tsserver plugin, editor only | Generates a .d.ts per module | tsserver plugin, plus codegen for tsc | Content mapper, inside tsc |
| Caught in CI | No | Yes, if generation runs first | Yes, if generation runs first | Yes |
| Extra files | None | One per module, committed or generated in CI | Generated declarations | None |
| Fresh clone | Works | tsc fails until generation runs | tsc fails until generation runs | Works |
| Editor updates | As you type | After save, with a watcher running | As you type | As you type |
| Go to definition | Opt-in, experimental | happy-css-modules (declaration maps) | Yes | Yes |
| Rename and references into the CSS | No | No | Yes | Yes, for names written as-is |
| Sass | Yes | Yes | No | Yes |
| TypeScript 7 | Not loaded (see below) | Works | Editor features are a tsserver plugin (see below); codegen works | Requires 7.1 |
css-modules-kit has more editor extras (a quick fix to add a missing class, className completion, updating imports when a module moves) and lint plugins. typed-css-imports needs no codegen step and supports Sass and localsConvention.
typescript-plugin-css-modules and css-modules-kit's editor features are tsserver plugins. The TypeScript team has said that existing tsserver plugins won't work with the TypeScript 7 codebase (microsoft/typescript-go#2154). In our test, the language server of TypeScript 7.1.0-dev.20261015.1 did not load a minimal tsserver plugin that TypeScript 5.9's tsserver loaded.
Limits
- Next.js: with webpack, names match fully. Turbopack also omits names for empty rules (
.x { }) and for@value, so those are typed butundefinedat runtime there. - No Less or Stylus modules.
- Editing a Sass partial doesn't update the modules that
@useit in the editor until the module itself is edited or the language server restarts.tscon the command line always compiles the current files. additionalDataonly in its string form; Vite's function form can't be expressed in tsconfig.json.- Names reflect the CSS as written. PostCSS plugins your bundler runs before CSS Modules (for example ones that generate classes) are not applied.
- Rename is only supported for names written as-is in the CSS. Renaming a quoted or converted name from a component (
styles["button-primary"],buttonPrimary) renames the uses in components but not the CSS, so the next type-check reports them; renaming it from the CSS file is refused. Rename those in the CSS and fix the components the errors point to. - A class declared in several rules goes to its first declaration.
- Default export only. Named imports (
import { button } from "./x.module.css") work in Vite but not in every bundler, so they aren't typed.
Contributing
Development setup, tests and the release process: CONTRIBUTING.md. Security: SECURITY.md.
License
MIT
