Deno-First Type Checking: Why typeCheck false in dnt Isn't What You Think

denotypescriptmonorepo

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: true mode (configured in deno.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:

  1. Compiles the source again using its internal TS compiler
  2. May produce different errors due to Node.js type definitions
  3. 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:

  1. Build time bloat: 2-3x longer builds
  2. False negatives: dnt's checker may pass when Deno's fails
  3. 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

  1. typeCheck: false in dnt is an optimization, not a gapβ€”if deno check runs first
  2. The real issue is declaration: falseβ€”this removes type hints for Node consumers
  3. Ensure build tasks depend on typecheck tasksβ€”don't skip the source of truth
  4. Watch for any stubsβ€”they defeat type safety silently
  5. 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.