mdx-tsc
v0.3.0
Published
A tsc-style CLI (and language server) that type-checks MDX
Maintainers
Readme
mdx-tsc
A tsc-style CLI that type-checks your MDX.
MDX lets you use imports, JSX components, and {expressions} inside Markdown —
but until now there was no way to type-check it in CI. Editors could show errors
(via the official mdx-analyzer), yet
nothing failed your build when an MDX file passed the wrong prop to a component
or referenced a field that doesn't exist.
mdx-tsc is that missing piece. Its mdx-tsc command is a drop-in tsc: it
type-checks .mdx alongside your .ts/.tsx, reports errors at the exact spot
in the MDX source, and exits non-zero when something is wrong.
$ mdx-tsc --project tsconfig.json
posts/intro.mdx(8,9): error TS2322: Type 'number' is not assignable to type 'string'.
posts/intro.mdx(10,21): error TS2339: Property 'toUpperCase' does not exist on type 'number'.It is built on the same Volar-based engine as the official MDX editor tooling
(@mdx-js/language-service) driven through Volar's runTsc, so its results
match what you see in your editor.
Install
npm install --save-dev mdx-tsc typescripttypescript is a peer dependency — mdx-tsc uses whichever version your project
already has.
Usage
mdx-tsc forwards every argument to tsc, so anything tsc accepts works:
mdx-tsc --project tsconfig.json # check once (CI)
mdx-tsc -p tsconfig.json --watch # re-check on changeAdd it to your scripts:
{
"scripts": {
"typecheck": "mdx-tsc --project tsconfig.json"
}
}A tsconfig that checks MDX
Point include at your .mdx files and turn on MDX checking:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"module": "preserve",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
},
// `mdx-tsc` always type-checks. `checkMdx` governs only the *official* MDX
// editor extension — leave it `true` if that's your only editor tooling, or
// set it `false` when using the mdx-tsc extension (see "Editor support").
"mdx": { "checkMdx": true },
"include": ["**/*.mdx", "**/*.ts", "**/*.tsx"],
}Frontmatter typing
mdx-tsc can type each document's frontmatter against a schema you declare,
matched by glob, in the same "mdx" section of your tsconfig that holds
checkMdx:
{
"mdx": {
"checkMdx": true,
"frontmatter": {
"content/blog/**/*.mdx": "./src/content.ts#BlogFrontmatter",
"content/docs/**/*.mdx": "./src/content.ts#DocFrontmatter",
},
},
}Each value is ./module#ExportedType. The referenced type can be a plain
TypeScript type or a Zod inferred type — mdx-tsc only reads
the static type, it never runs your schema:
// src/content.ts
export interface BlogFrontmatter {
title: string;
date: string;
tags: string[];
}
import { z } from "zod";
export const docSchema = z.object({ title: z.string(), order: z.number() });
export type DocFrontmatter = z.infer<typeof docSchema>;Both the frontmatter values and body usages of frontmatter are then
checked against the schema:
---
title: Hello
date: 20260706 # error: number is not assignable to string
tags: [intro]
author: Jane # error: 'author' does not exist in type 'BlogFrontmatter'
---
# {frontmatter.title}
Posted {frontmatter.publishedAt}. {/* error: publishedAt is not on BlogFrontmatter */}Wrong value types, unknown keys, and missing required fields are reported on the
offending line of the YAML. Files that match no glob keep an untyped (any)
frontmatter, so this is opt-in per content collection.
Provided components (MDXProvider / mdx-components.tsx)
Components you inject through MDXProvider or Next.js's mdx-components.tsx are
used in MDX without an import. Tell the type system about them once by augmenting
the global MDXProvidedComponents interface, and their props get checked
everywhere:
// mdx-env.d.ts
import type { Chart } from "./components.js";
declare global {
interface MDXProvidedComponents {
Chart: typeof Chart;
}
}
export {};<Chart data={42} /> {/* error: number is not assignable to number[] */}Make sure the .d.ts is covered by your tsconfig include.
In CI (GitHub Actions)
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx mdx-tsc --project tsconfig.jsonEditor support (squiggles)
The frontmatter and type checks are available live in your editor through the
mdx-tsc language server (mdx-tsc-language-server). It is additive: it
publishes only type and frontmatter diagnostics and advertises no other
features, so it runs alongside the official
MDX extension
without stepping on it.
- Keep the official MDX extension for syntax highlighting, hover, completion, and markdown features.
- Add mdx-tsc for type + frontmatter squiggles.
- Set
"mdx": { "checkMdx": false }in your tsconfig so the official extension stops emitting type diagnostics — mdx-tsc now owns those, so you don't see each error twice. (mdx-tscand the mdx-tsc server always type-check regardless of this flag.)
Setups:
- VS Code: the extension in
editors/vscodelaunches the server for.mdxfiles. - Neovim / Zed / Helix / any LSP editor: point the editor at the
mdx-tsc-language-serverbinary (stdio), passinginitializationOptions.typescript.tsdk(your TypeScriptlibdirectory).
What it checks
- ESM
import/exportresolution in MDX {expression}types- JSX component props (imported and provider-injected components)
- Frontmatter, against a per-glob schema you declare
- MDX parse errors — a document that can't be parsed is reported at the offending location (with the reason), so broken MDX fails the check instead of slipping through
Limitations
- MDX must be valid JavaScript + JSDoc. Like all MDX, the ESM in a document
is JavaScript — TypeScript-only syntax such as
export const x: number = …is not valid. Use JSDoc (/** @type {number} */) for annotations. - Remark/rehype transformers aren't applied (an upstream
@mdx-js/language-serviceconstraint), so syntax added by transformer plugins is not reflected in types.
How it works
mdx-tsc runs the real TypeScript compiler through Volar's runTsc, which
swaps in a program that understands .mdx. Each document is projected to a
virtual JSX module (via @mdx-js/language-service) with source maps back to the
MDX, so diagnostics land on the original file. mdx-tsc adds frontmatter typing on
top of that projection and reads its configuration from your tsconfig.
