When agents write the first draft, the type checker is the reviewer

More and more TypeScript reaches production as an agent's first draft and a human's quick skim. That changes what we ask of the language: the type checker is no longer a convenience for the person typing, it is the fastest reviewer standing between an agent's guess and your users.

But a type checker only reviews what the types can see. The way many of us learned to write production TypeScript, with classes, decorators, throw and async/await, hides a lot from it.

This article follows one arc: where TypeScript came from, why its early adopters, Angular and NestJS, were both blessed and cursed, and the happy path that most TypeScript code quietly assumes. It ends with where Effect enters. Each curse gets a short mention here, because each deserves its own article.

JavaScript was built in a hurry, then it ate the world

Brendan Eich designed JavaScript at Netscape in 1995 to add small bits of interactivity to web pages. By his own account, he prototyped it in 10 days in May 1995, under the code name "Mocha". Nobody planned for it to run banks, editors and servers.

Then it did. Ajax made pages feel like apps, jQuery smoothed over browser differences, and Node.js took the language to the server: first released in May 2009 and presented by Ryan Dahl at JSConf EU in November 2009. One language ran everywhere, and codebases grew from a few hundred lines of page tweaks into hundreds of thousands of lines shared by many teams.

The language had not changed to match. Dynamic typing that felt liberating in a 50-line script became a tax at scale: undefined is not a function in production, refactors done by grep and prayer, and function signatures you could only learn by reading the body.

TypeScript added types without asking you to leave JavaScript

Microsoft released TypeScript publicly in October 2012, as version 0.8. Anders Hejlsberg, lead architect of C#, was among the people who designed it. The pitch was modest: a superset of JavaScript that compiles to plain JavaScript, so a team could adopt it one file at a time instead of rewriting everything.

Types are checked at build time and describe shapes rather than class names (structural typing), which fits how JavaScript code is actually written. A missing field is now a red underline, not a 3 a.m. page:

ts
type User = { id: number; email: string }

function sendWelcome(user: User) {
  return `Welcome, ${user.email.toLowerCase()}`
}

sendWelcome({ id: 1 }) // compile error: property 'email' is missing

Editors gained reliable autocomplete and safe renames, and the compiler became a first reviewer of every change. Keep one detail in mind for later: all of these types are erased when the code runs.

Angular and NestJS bet early, and made TypeScript mainstream

TypeScript went from an interesting tool to an industry default when big frameworks bet on it. Angular was not an upgrade of AngularJS (1.x) but a ground-up rewrite, and a rewrite is the moment to pick a new foundation. On March 5, 2015, at the ng-conf conference in Salt Lake City, the Angular and TypeScript teams announced that Angular 2 would be developed in TypeScript. Google's AtScript language was retired in favor of TypeScript, which took on AtScript's annotation features. Today "Angular" means that rewrite and its successors (Angular 2 and above), while the original is called AngularJS. A huge community of front-end developers learned types, decorators and dependency injection as the default way to build an app.

In 2017, Kamil Myśliwiec released NestJS, a Node.js framework strongly inspired by Angular. It brought the same ideas to the backend: modules, providers, dependency injection and decorators on top of Express (Fastify is also supported).

ts
@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.users.findOne(id)
  }
}

If you came from Java or C#, this looked familiar and safe. That familiarity is exactly why teams adopted it so fast.

Blessed and cursed: every gift came with a cost

Angular and NestJS gave teams structure, conventions and a shared vocabulary, and they made TypeScript safe to bet a company on. They also moved a lot of behavior out of the places the type checker looks.

The gift

The curse behind it

Modules, controllers and providers give every project the same shape

Wiring is resolved by a runtime container, so a missing provider fails at boot, not at compile time

Decorators keep code short and declarative

Behavior hides in metadata that the type checker never inspects

Familiar object-oriented style for Java and C# developers

Classes that carry mutable state and hidden dependencies

Static types for requests and responses

Types are erased at runtime, so the same shape needs a second set of validation rules

Familiar throw and try/catch for failures

Errors are invisible in function signatures

Each row deserves its own article. For now only the last one matters, because it leads straight to the happy path.

The happy path: types describe success, not failure

A TypeScript signature promises what a function returns when everything goes right, and says almost nothing about how it can go wrong.

ts
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('request failed')
  return res.json()
}

The type says Promise<User>. It does not say that the call can fail with a network error, a 404 or a timeout, that res.json() returns data nobody validated, or that the function quietly needs an HTTP client and a base URL. Retries, timeouts and cancellation live in surrounding code that no type describes.

Agents make this worse. An agent reads the signature, trusts it, and writes await getUser(id) with no catch, because nothing told it otherwise and the compiler agrees. The code compiles, the visible tests pass, and the failure shows up in production. The type checker cannot review what the signature does not say.

Enter Effect: put failure and dependencies into the type

Effect is a TypeScript library that moves what signatures leave out into the type itself. An Effect<A, E, R> describes a program that succeeds with A, can fail with E, and needs R to run.

Version note: the snippets below use the Effect 3.x API (npm install effect@3). Effect 4.0 became stable on 1 October and renames some of these APIs, for example Schema.TaggedError becomes Schema.TaggedErrorClass and Schema.decodeUnknown becomes Schema.decodeUnknownEffect. Schema error types also changed, so the ParseError in the type comment is 3.x-specific. See the v3 to v4 migration notes before upgrading.

ts
import { Effect, Schema } from 'effect'

const User = Schema.Struct({ id: Schema.Number, email: Schema.String })
type User = typeof User.Type

class NotFound extends Schema.TaggedError<NotFound>()('NotFound', {
  id: Schema.String,
}) {}

class RequestFailed extends Schema.TaggedError<RequestFailed>()('RequestFailed', {
  cause: Schema.Unknown,
}) {}

const getUser = (id: string) =>
  Effect.gen(function* () {
    const res = yield* Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: (cause) => new RequestFailed({ cause }),
    })
    if (res.status === 404) return yield* new NotFound({ id })
    if (!res.ok) return yield* new RequestFailed({ cause: res.status })

    const body = yield* Effect.tryPromise({
      try: (): Promise<unknown> => res.json(),
      catch: (cause) => new RequestFailed({ cause }),
    })
    return yield* Schema.decodeUnknown(User)(body)
  })
// Effect<User, NotFound | RequestFailed | ParseError, never>

Three things changed compared with the happy-path version. First, the errors are Schema.TaggedError classes: each carries a _tag, so the compiler can tell NotFound from RequestFailed, and because they are Schemas they can also be encoded and decoded, for example when an error crosses a network boundary. Second, the as cast is gone. Schema.decodeUnknown(User) validates the response at the boundary, so User is something the program has checked, not something it assumed. Its failure is part of the error type too. Third, these errors are yieldable, so failing is a plain yield* of the error, with no Effect.fail wrapper.

A caller cannot quietly ignore any of these. The compiler asks you to handle each case, for example with Effect.catchTag. For agent-written code this matters: the failure paths and the validation are part of what the agent reads, and the type checker reviews them too, not only the happy path.

Effect is also more than a Result type like neverthrow. Neverthrow makes failures visible in the return type. Effect also tracks requirements (R), and runs your code on a fiber-based runtime: lightweight units of work that can be run concurrently, interrupted and cleaned up. That gives you retries, timeouts and cancellation without depending on how a Promise happens to behave. Fibers deserve their own article.

What comes next

TypeScript made JavaScript scale, Angular and NestJS made it mainstream, and their hidden costs sit in runtime magic and invisible failures. Effect makes the happy path honest, and in the agentic era honest types matter more, because an agent trusts the signature it reads.

Follow-up articles, one per curse:

  • Decorators and DI containers: the runtime magic the compiler cannot see

  • Types are erased: validating at the boundary with Effect Schema instead of casts

  • From throw to typed errors: failures as values

  • Fibers: concurrency, interruption and cleanup in Effect

  • Effect in practice: services, layers and testing alongside an agent

Sources