Deno-First Type Checking: Why typeCheck false in dnt Isn't What You Think
TL;DR: In a Deno-first monorepo, disabling type checking in dnt (Deno to Node Transform) isn't a type safety gapβit's a deliberate optimization. The real type checking happens at the Deno layer, not the npm build layer.
The Confusion
When auditing a Deno-first monorepo, you might encounter this pattern in build_npm.ts files:
await build({
typeCheck: false, // π± Is this disabling type safety?
// ...
});
Seeing typeCheck: false across multiple packages can trigger alarm bells:
- "Are we shipping untyped code?"
- "How are we catching type errors?"
- "Is this technical debt?"
The answer requires understanding the two-layer type checking architecture in a Deno-first repo.
The Two-Layer Architecture
Layer 1: Deno Type Checking (Source of Truth)
In a Deno-first monorepo, each package has a Moon task for type checking:
# packages/core/moon.yml
tasks:
typecheck:
command: deno
args:
- check
- src/**/*.ts
This runs Deno's native TypeScript checker with:
- Full
strict: truemode (configured indeno.json) - Access to Deno's type definitions
- Resolution via import maps
This is where type errors are caught.
Layer 2: dnt Build (Transformation)
The dnt build step transforms Deno code to Node.js-compatible npm packages:
// packages/core/build_npm.ts
await build({
entryPoints: ["./mod.ts"],
outDir: "./npm",
typeCheck: false, // Skip redundant re-check
declaration: true, // Generate .d.ts for Node consumers
// ...
});
When typeCheck: false, dnt skips running its internal TypeScript compiler for validation, trusting that the source was already validated by deno check.
The Data Flow
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DEVELOPMENT TIME β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β src/*.ts βββββββΊ deno check βββββββΊ β
Type errors caught β
β (strict: true) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β BUILD TIME β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β src/*.ts βββββββΊ dnt build βββββββΊ npm/ β
β (typeCheck: false) βββ esm/*.js β
β βββ esm/*.d.ts β
β βββ package.json β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β NODE.JS CONSUMPTION β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Node app βββββββΊ imports npm/ βββββββΊ tsc validates β
β (Next.js, (file: protocol) against .d.ts β
β Trigger) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Why This Works
1. Deno's Type Checker Is Authoritative
Deno uses a modified version of TypeScript's compiler that:
- Understands Deno's module resolution
- Respects import maps
- Enforces strict mode by default
If deno check passes, the code is type-safe.
2. dnt's Type Checking Is Redundant (Usually)
When dnt runs with typeCheck: true, it:
- Compiles the source again using its internal TS compiler
- May produce different errors due to Node.js type definitions
- Can choke on valid Deno patterns (like
Deno.*APIs)
Since the source was already validated, this is redundant work.
3. Build Speed Matters
Disabling type checking in dnt saves 5-15 seconds per package. In a monorepo with 20+ packages, this adds up:
| Configuration | Build Time |
|---------------|------------|
| typeCheck: true | ~4-5 minutes |
| typeCheck: false | ~1-2 minutes |
When typeCheck: false IS a Problem
The optimization becomes a liability when:
1. Declaration Generation Is Disabled
// β PROBLEM: No types for Node consumers
await build({
typeCheck: false,
declaration: false, // β This is the real issue
});
If declaration: false, Node.js apps importing the package get no type hintsβthey're effectively using any.
2. Type Stubs Replace Real Types
// β PROBLEM: Type safety defeated by stubs
const stubTypesContent = `export type AppType = any;`;
Deno.writeTextFileSync(originalTypesPath, stubTypesContent);
Some packages use any stubs during build to work around cross-package type resolution. This defeats the purpose of type safety.
3. Deno Check Isn't Actually Running
If the CI pipeline or pre-commit hooks skip deno check, the assumption breaks:
# β PROBLEM: No type checking at all
build-npm:
command: deno run -A build_npm.ts
# No deps on typecheck task!
Fix: Ensure build-npm depends on typecheck:
# β
CORRECT: Type check before build
build-npm:
command: deno run -A build_npm.ts
deps:
- ~:typecheck # Run type check first
Verification Checklist
To ensure your Deno-first repo maintains type safety:
β Deno Type Checking
# Should pass with no errors
moon run :typecheck
β Declaration Generation
// In each build_npm.ts
await build({
declaration: true, // Must be true
// ...
});
β
No any Stubs
# Search for type stubs
grep -r "export type.*= any" packages/*/build_npm.ts
# Should return empty or have documented exceptions
β Build Depends on Typecheck
# In moon.yml for packages that produce npm artifacts
build-npm:
deps:
- ~:typecheck
The Complete Picture
| Layer | Tool | Purpose | Should Fail Build? |
|-------|------|---------|-------------------|
| Source validation | deno check | Catch type errors | β
Yes |
| npm transformation | dnt build | Generate Node.js code | Only on syntax errors |
| Node consumption | tsc --noEmit | Validate against .d.ts | β
Yes |
Common Misunderstandings
"But what if dnt generates wrong types?"
dnt's type generation is mechanicalβit transforms TypeScript to TypeScript. If the source types are correct (validated by deno check), the output types will be correct.
The exception is when using Deno-specific types (like Deno.HttpClient) that don't exist in Node.js. These require shims:
await build({
shims: {
deno: true, // Adds @deno/shim-deno for Deno.* APIs
crypto: true, // Uses Node's crypto
},
});
"Shouldn't we check types twice for safety?"
Double-checking sounds safer, but it introduces:
- Build time bloat: 2-3x longer builds
- False negatives: dnt's checker may pass when Deno's fails
- Configuration drift: Two TypeScript configs to maintain
Trust the source of truth (Deno) and validate the output separately (Node apps' tsc).
"What about runtime type errors?"
TypeScript only catches compile-time errors. Runtime type errors (wrong API responses, invalid JSON, etc.) require runtime validation:
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
});
// Runtime validation
const user = UserSchema.parse(apiResponse);
This is orthogonal to typeCheck: false in dnt.
Recommended Configuration
For Most Packages
// build_npm.ts
await build({
entryPoints: ["./mod.ts"],
outDir: "./npm",
// Type checking
typeCheck: false, // Trust deno check
declaration: true, // Generate .d.ts for consumers
// Shims for Node.js compatibility
shims: {
crypto: true,
},
// Compiler options
compilerOptions: {
lib: ["ES2022", "DOM"],
target: "ES2022",
skipLibCheck: true,
},
});
For Packages with Deno APIs
await build({
// ...
shims: {
deno: true, // Shim Deno.* APIs
crypto: true,
},
});
Moon Task Configuration
# moon.yml
tasks:
typecheck:
command: deno check mod.ts
inputs:
- src/**/*.ts
- mod.ts
- deno.json
build-npm:
command: deno run -A build_npm.ts
deps:
- ~:typecheck # Enforce type checking before build
inputs:
- src/**/*.ts
- build_npm.ts
outputs:
- npm/
Key Takeaways
typeCheck: falsein dnt is an optimization, not a gapβifdeno checkruns first- The real issue is
declaration: falseβthis removes type hints for Node consumers - Ensure build tasks depend on typecheck tasksβdon't skip the source of truth
- Watch for
anystubsβthey defeat type safety silently - Trust the two-layer architectureβDeno validates source, Node apps validate consumption
Further Reading
This article emerged from an audit of a polyglot monorepo where typeCheck: false appeared in multiple packages. Initial concern about type safety gaps led to a deeper understanding of the Deno-first type checking strategy.