FullStack Course LogoFullStack Course
Module: TypeScript
TypeScript·178·14 MIN READ

178: TypeScript Tooling, Testing Types, Linting, Builds, and Monorepos

TOPICS COVERED: TypeScript Tooling, Testing Types, Linting, Builds, and Monorepos

Learning outcomes

By the end of this lesson, you should be able to:

  • explain and apply type checking in CI in a realistic implementation;
  • explain and apply linting in a realistic implementation;
  • explain and apply formatting in a realistic implementation;
  • explain and apply type-level tests in a realistic implementation; and
  • explain and apply monorepo boundaries in a realistic implementation.

These practices are most useful when they work together. The compiler checks the type model, lint rules catch patterns that the compiler does not express well, formatting removes avoidable review noise, type-level tests protect public type behavior, and package boundaries keep ownership clear. The goal is not to add tools for their own sake. The goal is to make important assumptions visible and to get useful feedback at the right point in development.

Prerequisites and retrieval

This lesson assumes the 01–06 foundation and the preceding lessons in this module. Before you read, retrieve one concrete example from an earlier project where one of these concerns appeared. Perhaps a CI check passed while a type error was still possible, a lint rule exposed an unhandled promise, or a package reached into another package's private files. Use that example as a reference point while you work.

The purpose is not to memorize tool names. It is to make a defensible decision inside a strict TypeScript codebase. In such a codebase, the compiler can model domain invariants, but static types do not replace runtime validation. Data arriving from an HTTP request, a file, a database, or another package still has to be checked at the boundary.

Terminology

  • Type checking in CI: Run tsc --noEmit or a project-reference build in CI so the submitted source is checked independently of editor diagnostics and transpilation.
  • Linting: Type-aware ESLint rules can catch unsafe promises, floating promises, misuse of assertions, and other patterns beyond the compiler's checks.
  • Formatting: Use a deterministic formatter so the same source is rendered consistently and code review can focus on behavior and contracts.
  • Type-level tests: Use @ts-expect-error or a dedicated type-test tool to prove that invalid calls remain rejected and that public generic inference stays stable across refactors.
  • Monorepo boundaries: Workspaces should expose explicit package APIs and avoid deep imports into another package's internals.
  • Incremental builds: Project references, build caches, and declaration boundaries can reduce feedback time in a large repository, but they should address a measured bottleneck rather than add complexity by default.

Mental model

Treat TypeScript Tooling, Testing Types, Linting, Builds, and Monorepos as one design problem with observable inputs, outputs, invariants, and failure modes. A typed codebase stays healthy when compiler rules, linting, type-level tests, package boundaries, and build configuration reinforce one another. It becomes harder to trust when each tool has a different interpretation of what is allowed.

A useful mental model is a set of gates around a change. The compiler checks whether the implementation is consistent with the type model. Linting checks for risky or misleading patterns. Formatting makes the representation deterministic. Type-level tests check the public type contract from a consumer's point of view. Package boundaries determine which contracts consumers are allowed to use. The build then verifies that those packages can be produced and consumed in the intended order.

None of these gates proves that the program is correct at runtime. A value parsed as unknown can pass the compiler only after the program has narrowed it, and a value coming from an untrusted source can still violate the assumptions represented by a type. The tools provide evidence about different failure modes; they are not interchangeable.

A useful interview and production sequence is:

text
requirement -> constraints -> model -> implementation -> failure analysis -> verification

Do not jump from a requirement directly to a library call or a configuration switch. First state what must remain true. Then choose the mechanism that enforces it, and decide what evidence will show that the mechanism is actually running.

Deep dive

1. Type checking in CI

Run tsc --noEmit or a project-reference build in CI. Diagnostics in an editor are helpful during development, but they are not a build guarantee: the editor may be using a different configuration or may not have checked every project. Likewise, a transpiler can emit JavaScript even when the TypeScript type check would fail.

For an application, tsc --noEmit is often the clearest check when another tool owns JavaScript emission. For a referenced workspace, a build that understands project references can check the dependency graph and produce declarations in the required order. Whichever command you choose, make it explicit in the CI script and run it against the same configuration developers expect to use.

Decision rule: Use type checking in CI deliberately when it makes a contract or invariant easier to prove. If a configuration change only reduces typing while hiding an assumption, prefer the more explicit design. A green CI result is useful only if the command covers the source and projects that will actually ship.

2. Linting

The compiler answers questions about types and many forms of invalid code. It does not, by itself, express every policy that keeps asynchronous or type-unsafe code understandable. Type-aware ESLint rules can identify unsafe promises, floating promises, misuse of assertions, and similar patterns beyond the compiler.

Keep the rule set intentional. A rule is valuable when it prevents a failure mode or makes a codebase's contract clearer. When a rule reports a problem, fix the root cause where possible. Blanket-disabling the rule, or adding an assertion merely to silence it, can turn useful feedback into false confidence. Also ensure that the lint command uses the intended TypeScript project configuration; a type-aware rule running with the wrong project can produce misleading failures or miss files altogether.

Decision rule: Use linting deliberately when it makes a contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design. Linting complements the compiler; it is not a replacement for type checking or runtime tests.

3. Formatting

Use a deterministic formatter so the same code is represented the same way on every machine. This keeps code review focused on behavior and contracts instead of indentation, line wrapping, or quote-style disagreements. Formatting is a collaboration mechanism, not a correctness check.

Formatting is not a substitute for linting or types. A formatter should not be expected to understand whether a promise is handled or whether an assertion is justified. In practice, the repository should make the formatting command easy to run locally and should either check or apply the result consistently in CI, according to the team's workflow.

Decision rule: Use formatting deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design. A stable formatter reduces noise, but it cannot validate the behavior of the program.

4. Type-level tests

Some of the most important TypeScript behavior exists only at compile time. A normal runtime test cannot prove that a consumer is rejected by the type system or that a generic function still infers the same useful type after a refactor. Type-level tests fill that gap.

Use @ts-expect-error for a focused negative case, or use a dedicated type-test tool when the project needs richer assertions. The directive should sit next to a call that is expected to fail. If a future change makes that call valid, TypeScript reports an unused directive, which is the signal that the public contract changed. Test both invalid calls that must remain rejected and valid calls whose inferred result is part of the API.

Decision rule: Use type-level tests deliberately when they make the contract or invariant easier to prove. If they only encode implementation details, prefer a test of the public type behavior instead. These tests do not validate runtime parsing, database behavior, or JavaScript emitted by the build, so they belong alongside—not instead of—those checks.

5. Monorepo boundaries

In a workspace, it is easy for one package to import another package's source file directly. The import may compile locally, but it bypasses ownership, can break package builds, and makes it unclear which symbols are supported for consumers. Workspaces should expose explicit package APIs and should avoid deep internal imports.

The useful distinction is between a TypeScript name-resolution convenience and a real package boundary. A path alias can make an import look clean to the compiler, but it does not automatically create a runtime-resolvable package, an export policy, or a build dependency. Use package names and explicit exports for the public surface, and make the build know which package must be built first.

Decision rule: Use monorepo boundaries deliberately when they make ownership and contracts easier to prove. If a shortcut only hides an assumption about build order or runtime resolution, prefer the explicit package API. A boundary is effective when consumers can use the supported entry point without depending on another package's internal layout.

6. Incremental builds

Project references, build caches, and declaration boundaries can reduce feedback time in a large repository. References divide the graph into projects with explicit dependencies; declarations let downstream projects consume a stable type surface; caches avoid repeating work when inputs have not changed. These mechanisms also introduce configuration and invalidation concerns.

Measure the bottleneck before adding complex caching infrastructure. A slow check may come from too many files in one project, an accidentally broad include pattern, expensive type-aware linting, or a dependency graph that is not split at useful boundaries. Faster tooling is valuable, but a fast check that omits a package or uses stale declarations is not a successful optimization.

Decision rule: Use incremental builds deliberately when they make feedback time acceptable without weakening verification. If the optimization makes it difficult to explain which inputs were checked, first simplify or measure the build configuration.

Worked example

Consider a strict TypeScript codebase where the compiler models domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts and identify which concept above owns each failure mode.

The important design move is separation. Parsing or validation belongs at the boundary. Domain rules belong in the domain or service layer. Persistence rules belong in the database or repository. Presentation rules belong in the client. Mixing these concerns can make a happy-path demo look shorter, but it pushes unrelated assumptions into the same function and makes edge cases much harder to reason about.

Here is a small boundary parser for a branded task identifier:

ts
type TaskId = string & { readonly __brand: 'TaskId' };

function parseTaskId(value: unknown): TaskId {
  if (typeof value !== 'string' || value.length === 0) {
    throw new TypeError('Invalid task id');
  }
  return value as TaskId;
}

The unknown input is intentional: data at a runtime boundary has not earned a more specific type yet. The check establishes only the invariant shown here—that the value is a non-empty string. The assertion creates the brand for the compiler after that check; it does not inspect the value at runtime, and it does not prove that a task with that ID exists in storage. Those are separate responsibilities.

Walk the example through at least four cases: the normal path, an empty or missing value, a duplicate/retry/concurrent path where relevant, and a dependency failure. For each case, state which layer detects the problem and what the caller observes. For example, the parser can reject an empty value, while a repository may report a missing record or a uniqueness conflict, and a dependency failure may require a structured error and retry policy. This is the level of explanation expected in a senior code review or technical interview.

The tooling should support that separation. A type check can ensure callers pass a TaskId after parsing. A lint rule can flag an unhandled promise from the repository. A type-level test can protect the public function from accepting an arbitrary string. A package boundary can ensure consumers import the parser or domain API rather than reaching into its implementation file. Runtime tests still need to exercise malformed input and dependency behavior.

Production perspective

Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality workloads. Tooling gives fast feedback, but the system still needs explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence.

When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and all network input is untrusted. A compile-time type such as TaskId does not make a request trustworthy, authorize a user, or validate data that arrived after compilation.

Guided lab

Configure a two-package TypeScript workspace with strict checking, ESLint, formatting, type-level negative tests, and a CI script. Break one package API intentionally and verify that the consumer fails at compile time. Then restore the API and verify that the workspace passes through the same commands CI runs.

Complete the lab with this discipline:

  1. Write the requirement and two non-requirements.
  2. List input, output, and error contracts before implementation.
  3. Implement the smallest correct vertical slice.
  4. Add at least one invalid-input test and one edge-case test.
  5. Instrument or inspect the behavior instead of guessing. Record which command failed and whether the failure came from the compiler, linter, formatter, type-level test, or package build.
  6. Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
  7. Explain one alternative design and why you did not choose it.
  8. Record a short “what would break at 10× scale?” note. Include whether the issue would be build time, package coupling, cache invalidation, runtime load, or operational reliability.

There is no need to make the workspace large. The point is to observe the feedback loop: introduce a contract, violate it from a consumer, identify the owning check, and confirm that the failure is visible before runtime.

Edge cases and failure modes

  • Type checking in CI: Test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Also verify that CI checks the intended project rather than only the package that happens to be easiest to invoke.
  • Linting: Test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Include an unhandled or floating promise when asynchronous work is part of the design.
  • Formatting: Test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Confirm that a formatting-only change is distinguishable from a behavior change in review.
  • Type-level tests: Test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check both rejected calls and valid calls whose inferred types are part of the public contract.
  • Monorepo boundaries: Test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Attempt a deep internal import and confirm that the intended package API or export configuration prevents it.

Common mistakes and debugging

  • Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
  • Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary” any values.
  • Testing only the happy path and therefore discovering contracts only after integration.
  • Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
  • Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.

When a check fails, start with the smallest failing case. Identify the command and configuration that produced the result, inspect the actual value or generated declaration where relevant, and trace the boundary where the invariant first becomes false. A missing CI failure may indicate that the wrong project was checked; a type-level test failure may indicate a changed public inference contract; a module-resolution failure may indicate a path alias that is not a real package boundary. Fix the owning layer rather than adding a downstream patch that hides the symptom.

Interview questions

  1. What problem does type checking in CI solve, and what trade-off or failure mode would make you choose a different approach?
  2. What problem does linting solve, and what trade-off or failure mode would make you choose a different approach?
  3. What problem does formatting solve, and what trade-off or failure mode would make you choose a different approach?
  4. What problem do type-level tests solve, and what trade-off or failure mode would make you choose a different approach?
  5. What problem do monorepo boundaries solve, and what trade-off or failure mode would make you choose a different approach?

Checkpoint

Without notes, explain TypeScript Tooling, Testing Types, Linting, Builds, and Monorepos to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.

As a self-check, make sure your explanation distinguishes compile-time evidence from runtime validation, and a TypeScript path alias from a package boundary. If you cannot say which command or layer would reveal a failure, the model is not yet concrete enough.

Mastery checklist

  • I can define the core terms precisely.
  • I can choose a design from requirements instead of from habit.
  • I can implement and test the normal path and edge cases.
  • I can explain the runtime, storage, or complexity cost.
  • I can identify which layer owns validation, errors, and recovery.
  • I can compare at least two reasonable alternatives.
  • I can explain how the design changes at larger scale or stricter reliability.

References

Reader page: /typescript/lesson/178/typescript-tooling-testing-types-linting-builds-and-monorepos