Skip to main content
ASoc
Comparison

TypeScript vs TypeScript + SWC: Two Jobs, Two Tools

SWC compiles fast and never type-checks; tsc does the opposite. This codebase's own tsconfig and a Turbopack defect it actually shipped show exactly where that split shows up in practice.

The ASoc Team8 min read

TypeScript on its own is a type checker with a compiler bolted on; SWC is a Rust compiler with no type checker at all. "TypeScript vs SWC" is really "who does which of those two jobs." This codebase's answer is the one nearly every modern Next.js app ships without deciding to: SWC does the fast per-file transform, a separate tsc pass does the type-checking, and the project's own config carries two small, easy-to-miss adjustments that exist only because a single-file transpiler can't see the whole program the way tsc can.

The two jobs, and who does each one here

Jobtsc (plain TypeScript)SWCThis codebase
Type-check (catch a wrong type before runtime)YesNo — SWC strips types without reading themnext build runs a full type-check as a separate step
Transform TS/JSX → JSYes, but whole-program and slowerYes, per file, in Rust — fastSWC, via next build's built-in compiler
Needs a full program graphYes (cross-file inference, some type-only elision)No — one file in, one file outSplit across the two tools above

Next.js has used SWC as its default compiler since version 12, so this project never had to opt in — there's no next.config.ts flag for it, no .babelrc, and no babel.config.js anywhere in the repo:

$ find . -maxdepth 2 -iname ".babelrc*" -o -iname "babel.config.*"
(no output)

Babel was the compiler SWC replaced project-wide; its absence here isn't a choice this codebase made, it's the default nobody had to touch. What is a choice, and what shows up directly in tsconfig.json, is accommodating the fact that a per-file transpiler can't do everything tsc can.

The setting SWC actually requires: isolatedModules

// tsconfig.json
{
  "compilerOptions": {
    "isolatedModules": true,
    // ...
  }
}

tsc compiles with the whole program in view — every file, every import, every type — so it can safely erase a type-only re-export or resolve a const enum by inlining its values everywhere they're used. SWC compiles one file at a time. Handed a single file in isolation, it cannot always tell whether export { Foo } is exporting a value or only a type, and it has no way to inline a const enum defined in a different file it hasn't read.

isolatedModules: true tells tsc itself to flag any construct that only works with whole-program knowledge, so the error shows up in your editor and in npm run build's type-check pass — not as a silent miscompile once SWC gets a file it can't safely handle alone. It's a compatibility flag for a compiler that isn't tsc, sitting in the config of a project that mostly forgets tsc isn't the one doing the compiling.

Where SWC sits among the other fast compilers

SWC isn't the only per-file, type-stripping transpiler in common use — esbuild (Go) and Babel with @babel/preset-typescript (JavaScript) solve the same narrow problem in the same way: strip the types, don't check them, and leave tsc to run separately if anyone wants actual type safety.

ToolLanguage it's written inType-checks?Where this codebase would meet it
tscTypeScript (self-hosted)YesThe separate type-check pass inside next build
SWCRustNoNext.js's default compiler — the one actually running here
esbuildGoNoVite's default transform; not part of this stack, but vitest (also in package.json) is Vite-based under the hood
Babel + @babel/preset-typescriptJavaScriptNoWhat SWC replaced as Next.js's default in v12; absent from this repo entirely

The pattern across all three non-tsc rows is the same: speed comes from never building the whole-program model tsc needs to type-check, which is exactly the capability isolatedModules exists to keep projects honest about. Reach for tsc --noEmit (or a build that runs it, as this one does) as the one step none of the fast compilers can substitute for.

The defect this codebase actually hit: passing SWC a function reference

next.config.ts wires up MDX for the blog, and its comment records a build that worked in one dev mode and silently broke in another:

// next.config.ts
/**
 * Plugins are named as STRINGS, not imported: Turbopack runs the MDX
 * pipeline in Rust and cannot receive JavaScript function references.
 * Passing the imported plugin here builds but silently drops it.
 */
const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-gfm"],
    rehypePlugins: ["rehype-slug"],
  },
});

The tempting version — the one every @next/mdx example shows — imports the plugin and passes the function itself:

// what looks right and silently isn't, under Turbopack
import remarkGfm from "remark-gfm";
const withMDX = createMDX({ options: { remarkPlugins: [remarkGfm] } });

That's valid JavaScript, and Webpack-based builds run it fine — the plugin function crosses from the Node config process into the JS-based MDX pipeline with nothing in between. Turbopack's MDX pipeline runs in Rust. A JavaScript function reference doesn't serialize across that boundary; there's no error, no warning, just a plugin that silently never runs. remark-gfm is what makes | a | b | render as a table instead of a paragraph of literal pipe characters — on a blog whose comparison posts are mostly tables, that's not a subtle miss. The fix is the string-named form above: Rust-side code resolves "remark-gfm" by name from its own plugin registry instead of receiving a JS closure it can't call.

It's the same category of problem isolatedModules guards against, one abstraction layer up: a tool that isn't tsc — or in this case isn't even JavaScript — doesn't get to assume it has access to everything a Node-based pipeline takes for granted.

Where this bites people switching to SWC

SymptomCauseFix
isolatedModules error on a type re-exportexport { SomeType } where SomeType is a type, not a valueWrite export type { SomeType } so a per-file compiler can tell it's type-only without checking the source module
A const enum "works" in the editor, breaks the buildSWC can't inline values it hasn't seen definedUse a regular enum, or as const plus a union type
Legacy decorator metadata (emitDecoratorMetadata) silently does nothingSWC's decorator support doesn't include full tsc-style metadata reflectionConfirm the library's docs support SWC specifically, not just tsc, before adopting decorator-heavy metadata reflection
A build-time codegen step that ran fine under Webpack does nothing under TurbopackA JS function/object passed where the pipeline expects a string or JSON-serializable valueCheck whether the tool's Turbopack docs want a string/name instead of an import
Type error only shows up in CI, never locallynext dev's fast refresh loop doesn't always run the full type-check pass SWC skipsRun tsc --noEmit (or next build) locally before pushing, not just next dev

Frequently asked questions

Does SWC type-check my code? No. SWC strips TypeScript syntax and emits JavaScript; it never reads a .d.ts file or resolves a type. Type errors are caught by a separate tsc pass, which is why next build — not next dev — is this project's actual gate for "does the code type-check," per this repo's own build convention.

Is Turbopack the same thing as SWC? No, but they're related. SWC is the Rust compiler that transforms one file's TypeScript/JSX into JavaScript. Turbopack is Next.js's Rust-based bundler — the thing that decides which files to compile, in what order, and how to cache the result. Turbopack calls SWC (among other tools) to do the actual per-file transform; the MDX-plugin defect above is a Turbopack limitation, not an SWC one.

Can I use SWC without Next.js? Yes — @swc/core and the swc CLI compile standalone, and tools like Vite and Jest (via @swc/jest) can use it as a faster substitute for Babel or ts-jest. None of them add type-checking either; you still run tsc --noEmit alongside whichever one you pick.

Why does Next.js still run tsc if SWC already compiles everything? Because compiling and type-checking are different jobs, and only one compiler in this pipeline does the second one. SWC's speed comes partly from skipping it — dropping type information without verifying it is what makes a per-file transform possible in the first place.

How do I know SWC, not Babel, is actually compiling my Next.js app? By default you don't have to check — SWC has been the default since Next.js 12, and it only steps aside if the project has its own Babel config (a .babelrc or babel.config.js) or a plugin that requires one. This repo has neither, which is itself the confirmation: nothing in it opts back into the slower path.

Templates in this post

ASoc Till markets a POS system with a live register preview and app-store download CTAs, ASoc Timbre is an AI voice-generator landing page spanning 170+ languages, and ASoc Uptime markets web hosting with domain search and tiered pricing — all built on the same Next.js + TypeScript toolchain audited above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison10 min read

Vercel Analytics vs Google Analytics 4: The Consent Banner Decides It

A banner-gated tool measures the subset of visitors who agreed to be measured. The cookieless trade, the typed event union, and the CSP origin we nearly added for nothing.

Read more
Comparison7 min read

Vercel vs. AWS Amplify: The Bundled Backend Is the Real Question

13 runtime dependencies, zero AWS SDK packages, and a build that splits 392 static pages from 8 dynamic routes with no deployment config authored for either.

Read more