Build Your Own Bedrock · Part 2

Sign a contract with your compiler

TypeFirst · AgileLabs Engineering · 6 min read

Every guarantee in this series — errors as values, unforgeable types, parse-don't-validate — rests on one assumption: the types tell the truth. TypeScript, out of the box, lets you lie to it four different ways. So step 0 of building your bedrock is not writing code. It's configuration: a contract with the compiler, signed before the first type exists, that neither you nor anyone on your team can quietly break.

The compiler side

tsconfig.json turns on everything TypeScript can check:

// tsconfig.json
{
  "compilerOptions": {
    // …
    "noImplicitAny": true,
    "noImplicitThis": true,
    "strict": true,
    "strictNullChecks": true
  }
}

strict already implies the others; bedrock spells them out anyway because they are the point. strictNullChecks in particular is load-bearing: Part 3's entire null policy — Maybe<T> = T | null — only means something when the compiler refuses to let a nullable value pass as a non-null one.

The linter side: the four bans

The compiler can't ban its own escape hatches — they're language features. ESLint can. This file is the heart of the contract:

// devops/eslint/strict-ts.json
{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "TSNonNullExpression",
        "message": "Non-null assertions (`!`) are not allowed. …"
      },
      {
        "selector": "TSAsExpression",
        "message": "Type assertions (`as`) are not allowed. …"
      },
      {
        "selector": "TSTypePredicate",
        "message": "Type predicates (`is`) are not allowed. …"
      }
    ]
  }
}

Together with @typescript-eslint/no-explicit-any set to "error" in the root .eslintrc.json, that's four bans. What each one buys you:

Law, not culture

The root config wires it together, and one more file bans React hooks on the frontend (importNamePattern: "use*" — that story is Part 14's). Then the scripts make it law:

// .eslintrc.json + package.json
"extends": [
  "./devops/eslint/boundaries.json",
  "./devops/eslint/strict-ts.json",
  "./devops/eslint/strict-react.json",
  // …
],

"tsc":  "tsc --noEmit",
"lint": "esw ./ --ext .ts --ext .tsx --max-warnings=0"

--max-warnings=0 is the signature on the contract. A convention enforced in code review is culture, and culture decays: it goes soft under deadline pressure, it doesn't transfer to new hires, it loses arguments to "just this once". A convention enforced by CI is mechanical law. Nobody argues with a red build, so nobody has to be the police — which is precisely what makes the rest of this series cheap. When Part 5 claims an Opaque value can't be forged, that claim holds because as is banned here, in a JSON file, forever.

In your language

Every ecosystem has a maximum-strictness switch; step 0 is flipping it before the first commit. Rust: #![deny(warnings)] and clippy's unwrap_used lint to make panics greppable. Kotlin: detekt with a !! ban. Swift: SwiftLint's force_unwrapping and force_cast rules. Python: mypy --strict plus Ruff, and treat cast() and type: ignore as the things to ban. Go: golangci-lint with errcheck, so an ignored error fails the build. Elm simply ships this way — no escape hatches were ever in the language.

Why these four, in depth? The companion post — We banned any, as, is and ! from our TypeScript — covers the case for each ban and what day-to-day code looks like without them.

← Part 1: The rules of the game · All posts · Part 3: Maybe: a null policy in 52 lines →