# 23 TypeScript Tools for Making Software Explicit in the AI Era

> Source: <https://dev.to/remojansen/23-typescript-tools-for-making-software-explicit-in-the-ai-era-20hb>
> Published: 2026-08-21 08:53:59+00:00

In my previous articles, [I argued that AI is changing the role of constraints in software development](https://dev.to/remojansen/from-rigidity-to-explicitness-how-ai-changes-the-role-of-constraints-in-software-5cp5).

For a long time, we treated constraints as friction. Static types felt slower than dynamic code. Schemas felt restrictive compared to flexible data. Explicit workflows felt more cumbersome than letting an application decide what to do at runtime.

But AI changes the economics.

Writing code is becoming cheaper. Understanding what the code is supposed to do is not. And verifying that generated code actually does what we intended is becoming one of the most important parts of software development.

This leads to a simple principle:

The more important an assumption is, the more valuable it is to make that assumption explicit.

This is particularly important with TypeScript. TypeScript already makes some things explicit, but the type system cannot express everything. It cannot tell us what happens when an HTTP request fails, validate JSON received from an external service, tell us which application states are legal, describe database relationships, enforce module boundaries, or define how a distributed workflow should behave after a process crashes.

Those things are often left implicit.

That is exactly where AI-assisted development becomes difficult. If a constraint exists only in someone's head, a prompt, a convention, or an undocumented assumption, the AI has to infer it. And inference is exactly where we don't want critical business rules to live.

The interesting thing about the TypeScript ecosystem is that there are now tools for making almost every layer of a system more explicit.

Here are 23 of them.

[Effect](https://effect.website/) makes effects, errors, dependencies, concurrency, resources, and schemas explicit.

Without Effect, we might write:

``` js
async function getUser(id: string) {
  const response = await fetch(`/users/${id}`);

  if (!response.ok) {
    throw new Error("Request failed");
  }

  return response.json();
}
```

There is a lot of implicit information here. The function performs I/O. It can fail. It returns unvalidated external data. It depends on `fetch`

. The caller has to discover all of this by reading the implementation.

With Effect, those concerns become part of the program's structure:

``` js
const getUser = (id: string) =>
  Effect.gen(function* () {
    const response = yield* HttpClient.get(`/users/${id}`);
    return yield* decodeUser(response);
  });
```

The important difference is not syntax. It is information density. The code communicates what the operation does, what it can fail with, what it depends on, and how it composes with other effects.

Effect turns invisible operational behavior into explicit program structure.

[Zod](https://zod.dev/) makes runtime data validation explicit.

Without it:

``` js
const user = await response.json();

sendEmail(user.email);
```

The programmer is implicitly assuming that the response contains an `email`

property.

With Zod:

``` js
const User = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email()
});

const user = User.parse(await response.json());

sendEmail(user.email);
```

Now the assumption is explicit. The schema can be read by a human, used by the application, tested automatically, and inspected by AI.

Types describe what we believe. Runtime schemas verify what we actually received.

[io-ts](https://gcanti.github.io/io-ts/) makes the boundary between unknown runtime data and typed data explicit.

Without a codec:

``` js
type Payment = {
  id: string;
  amount: number;
};

const payment: Payment = await getPayment();
```

The type says `payment`

is valid, but the runtime data has not actually been checked.

With io-ts:

``` js
const Payment = t.type({
  id: t.string,
  amount: t.number
});

const result = Payment.decode(await getPayment());
```

Now there is an executable description of the boundary between unknown data and trusted data. The codec can also be tested against invalid inputs.

[Valibot](https://valibot.dev/) makes runtime validation and schemas explicit with a lightweight API.

Without it:

```
function createAccount(input: any) {
  // Assume input is valid.
}
```

With Valibot:

``` js
const AccountInput = v.object({
  name: v.string(),
  email: v.pipe(v.string(), v.email()),
  age: v.pipe(v.number(), v.integer(), v.minValue(18))
});

function createAccount(input: unknown) {
  const account = v.parse(AccountInput, input);
}
```

The requirements are no longer hidden inside the implementation. They are represented as data that humans, tests, tools, and AI can inspect.

[TypeBox](https://sinclairzx81.github.io/typebox/) makes JSON Schema and TypeScript type definitions explicit and connected.

Without it:

```
interface CreateOrder {
  productId: string;
  quantity: number;
}
```

The TypeScript type describes the application, but external validators and JSON Schema consumers need another representation.

With TypeBox:

``` js
const CreateOrder = Type.Object({
  productId: Type.String(),
  quantity: Type.Integer({ minimum: 1 })
});
```

The schema becomes a first-class artifact that can drive validation, tooling, documentation, and generation.

One explicit contract is better than five independently maintained descriptions of the same contract.

[XState](https://stately.ai/docs/xstate) makes states, events, transitions, and actors explicit.

Without a state machine:

```
if (loading) {
  // ...
}

if (error) {
  // ...
}

if (data) {
  // ...
}
```

As the application grows, combinations can appear that were never intended. The actual state model exists implicitly in conditionals.

With XState:

``` js
const machine = createMachine({
  initial: "idle",

  states: {
    idle: {
      on: { SUBMIT: "submitting" }
    },

    submitting: {
      on: {
        SUCCESS: "success",
        FAILURE: "failure"
      }
    },

    success: {},
    failure: {
      on: { RETRY: "submitting" }
    }
  }
});
```

Now the possible states and transitions are explicit. An AI does not need to infer which transitions are legal. Tests can enumerate them, tooling can visualize them, and invalid transitions can be rejected.

This is the same idea I explored in my article about AI agents and finite state machines: don't ask the AI to infer the workflow when you can give it the workflow.

[Prisma](https://www.prisma.io/) makes database models, relationships, migrations, and generated data access types explicit.

Without a schema-first ORM:

``` js
const users = await db.query(
  "SELECT * FROM users WHERE organisation_id = ?",
  [organisationId]
);
```

The relationship between users and organisations is hidden in the database.

With Prisma:

```
model User {
  id             String       @id
  email          String
  organisationId String
  organisation   Organisation @relation(fields: [organisationId], references: [id])
}

model Organisation {
  id    String @id
  users User[]
}
```

The model and relationship are explicit, and the schema can generate types and migrations.

[Drizzle ORM](https://orm.drizzle.team/) makes database schemas, SQL relationships, and query types explicit in TypeScript.

Without it:

``` js
const result = await db.query(
  "SELECT id, name FROM users WHERE active = true"
);
```

The SQL is explicit, but the relationship between the query and its TypeScript result is not.

With Drizzle:

``` js
const users = await db
  .select({
    id: usersTable.id,
    name: usersTable.name
  })
  .from(usersTable)
  .where(eq(usersTable.active, true));
```

The schema, query, selected fields, and result type can all be connected.

[Kysely](https://kysely.dev/) makes SQL queries and their result types compile-time checked.

Without it:

``` js
const result = await db.query(`
  SELECT id, email
  FROM users
  WHERE organisation_id = $1
`, [organisationId]);
```

The AI has to infer the result shape from the SQL.

With Kysely:

``` js
const result = await db
  .selectFrom("user")
  .select(["id", "email"])
  .where("organisation_id", "=", organisationId)
  .execute();
```

The query is constrained by the database type. Invalid tables or columns can become compile-time errors.

The best constraint is often the one that fails automatically.

[Pulumi](https://www.pulumi.com/) makes infrastructure resources, dependencies, and configuration explicit as typed code.

Without infrastructure-as-code, the architecture might be a collection of console settings, scripts, environment variables, documentation, and tribal knowledge.

With Pulumi:

``` js
const bucket = new aws.s3.Bucket("uploads");

const policy = new aws.s3.BucketPolicy("uploads-policy", {
  bucket: bucket.id,
  policy: ...
});
```

Infrastructure relationships become part of the program. The AI can inspect them, deployment tooling can inspect them, and infrastructure can be previewed before it changes.

[AWS CDK](https://aws.amazon.com/cdk/) makes cloud infrastructure and its relationships explicit as TypeScript constructs.

Without CDK, the relationship between an API and Lambda might exist only in cloud configuration.

With CDK:

``` js
const api = new apigateway.RestApi(this, "Api");

const handler = new lambda.Function(this, "Handler", {
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: "index.handler",
  code: lambda.Code.fromAsset("lambda")
});

api.root.addMethod("GET", new apigateway.LambdaIntegration(handler));
```

The relationship is now explicit, reviewable, synthesizable, and testable.

[dependency-cruiser](https://github.com/sverweij/dependency-cruiser) makes module dependency rules explicit and enforceable.

Imagine:

```
UI
 ↓
Application
 ↓
Domain
 ↓
Infrastructure
```

But nothing prevents:

```
Domain
 ↓
Infrastructure
```

With dependency-cruiser, rules such as "domain must not depend on infrastructure" become executable.

An AI can read the rules, CI can enforce them, and generated imports that violate them can fail automatically.

Architectural decisions should be executable whenever possible.

[Madge](https://github.com/pahen/madge) makes module dependency graphs and circular dependencies explicit.

Without tooling, a cycle such as this can be difficult to see:

```
A → B → C → D → A
```

With Madge:

```
madge --circular src/
```

the graph becomes inspectable and circular dependencies can be detected automatically.

[eslint-plugin-boundaries](https://github.com/j5ik2o/eslint-plugin-boundaries) makes architectural layer and module boundaries explicit and enforceable.

Without it:

``` js
import { UserRepository } from "../../infrastructure/database";
```

might compile perfectly even when application code is not supposed to access infrastructure directly.

With boundaries configured, the rule can become:

```
application → domain
application → infrastructure ❌
domain → infrastructure ❌
```

ESLint can reject the violation. The AI does not need to remember the architectural rule because the tool enforces it.

[tRPC](https://trpc.io/) makes client/server procedure contracts explicit and type-safe.

Without a shared contract:

```
api.post("/users", {
  name,
  email
});
```

The server might expect completely different field names. The contract exists implicitly across two implementations.

With tRPC:

``` js
const createUser = publicProcedure
  .input(
    z.object({
      name: z.string(),
      email: z.string().email()
    })
  )
  .mutation(({ input }) => {
    // ...
  });
```

The procedure and its input contract become part of the program. The client can consume that contract directly, and the compiler provides feedback when it changes.

[ts-rest](https://ts-rest.com/) makes HTTP endpoints, parameters, payloads, and responses explicit as shared contracts.

Without it:

```
fetch("/api/orders", {
  method: "POST",
  body: JSON.stringify(order)
});
```

The AI has to infer what the endpoint expects, what it returns, and which status codes are possible.

With a contract:

``` js
const contract = c.router({
  createOrder: {
    method: "POST",
    path: "/orders",
    body: CreateOrder,
    responses: {
      201: Order,
      400: ErrorResponse
    }
  }
});
```

The HTTP boundary becomes explicit and can drive server implementation, client usage, tests, and documentation.

[oRPC](https://orpc.unnoq.com/) makes RPC procedures and their client/server contracts explicit and type-safe.

Without a contract:

```
client.createUser(...)
```

The implementation determines what arguments are accepted.

With an explicit procedure contract, inputs, outputs, errors, and procedure identity become machine-readable. The AI is no longer asked to figure out how the API probably works; it is asked to implement against a defined contract.

[OpenAPI Initiative](https://www.openapis.org/) makes network API contracts explicit independently of implementation language.

Without OpenAPI, an API might be described by a README:

```
POST /users

Probably takes:
{
  name,
  email
}

Returns a user.
```

With OpenAPI:

```
paths:
  /users:
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUser'
      responses:
        '201':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
```

The API becomes a formal artifact. Humans can read it, machines can validate it, tools can generate clients, and AI can consume it.

[Orval](https://orval.dev/) makes OpenAPI contracts executable by generating strongly typed clients.

Without generation, an AI might repeatedly write:

```
fetch("/api/users/123")
```

and have to infer the method, parameters, request body, response type, and error handling.

With Orval, the OpenAPI specification becomes the source from which client code is generated. The AI can use generated code whose structure already reflects the contract.

Explicitness is even more powerful when it can be converted into automation.

[Proto.Actor](https://proto.actor/) makes actors, messages, supervision, identity, and distributed communication explicit.

Without an actor model:

```
await sendMessage(node, message);
```

What happens when the node disappears? Who owns the state? Should the message be retried? Who supervises the failure? Can two messages be processed concurrently?

An actor model gives these concepts explicit names and structures:

```
Actor
  ↓
Message
  ↓
State
  ↓
Supervision
  ↓
Failure handling
```

The distributed system now has a vocabulary that both humans and AI can reason about.

[Dapr](https://dapr.io/) makes distributed-system capabilities explicit through abstractions such as actors, state, pub/sub, and service invocation.

Without a distributed abstraction:

```
await redis.set(key, value);
await kafka.publish(topic, message);
await fetch(serviceUrl);
```

The distributed semantics are scattered throughout the application.

With Dapr, concepts such as state, pub/sub, service invocation, and actors become explicit architectural capabilities. The AI can reason about intent instead of reconstructing architecture from arbitrary infrastructure calls.

[Temporal](https://temporal.io/) makes durable workflows, activities, retries, timers, failures, and long-running execution explicit.

Without Temporal:

```
await chargeCard();
await createOrder();
await sendEmail();
```

What happens if the process crashes after `chargeCard()`

? Should it be retried? What if `createOrder()`

fails? Should the email be sent twice?

With Temporal:

```
await chargeCard();

await proxyActivities<typeof activities>({
  startToCloseTimeout: "1 minute",
  retry: {
    maximumAttempts: 3
  }
}).createOrder();

await sleep("1 day");
```

The workflow, retries, timers, activities, and durability semantics become explicit.

This is especially important for AI agents.

Let AI decide what requires intelligence. Let the workflow engine handle what requires reliability.

[fp-ts](https://gcanti.github.io/fp-ts/) makes functional effects, optionality, errors, and composition explicit through types.

Without it:

```
function findUser(id: string): User | undefined {
  // ...
}
```

The caller has to remember to handle the missing case.

With explicit functional structures:

``` js
const findUser = (id: string): Option<User> => {
  // ...
};
```

Or:

``` js
const createUser = (
  input: CreateUser
): Either<ValidationError, User> => {
  // ...
};
```

The possibility of failure is no longer an informal convention. It is part of the function's type.

If a function returns `User`

, the AI may assume it always has a user. If it returns `Either<ValidationError, User>`

, the failure path is visible and the compiler can help verify that it was handled.

These tools look very different. Effect is not Prisma. Prisma is not XState. XState is not OpenAPI. OpenAPI is not Temporal.

But they are all solving a similar problem.

They take something that would otherwise be implicit and give it a representation.

| Implicit | Explicit |
|---|---|
| Errors | Effect / `Either`
|
| External data assumptions | Zod / io-ts / Valibot |
| JSON Schema | TypeBox |
| Application state | XState |
| Database relationships | Prisma / Drizzle |
| SQL result types | Kysely |
| Infrastructure relationships | Pulumi / CDK |
| Module architecture | dependency-cruiser / Madge |
| Architectural boundaries | eslint-plugin-boundaries |
| API contracts | tRPC / ts-rest / oRPC / OpenAPI |
| Generated API clients | Orval |
| Actor semantics | Proto.Actor / Dapr |
| Durable workflows | Temporal |
| Optionality and errors | fp-ts |

The important word here is **representation**.

If a rule only exists in a developer's head, there is very little an AI can do with it. If the rule exists only in a prompt, the AI can forget it. If it exists only in documentation, it can become stale.

But if the rule exists in code, a schema, a state machine, a contract, a dependency rule, or a workflow definition, it becomes part of the system.

And once it is part of the system, we can automate around it.

Consider an AI-generated change.

Without explicit constraints, verification might look like this:

```
AI generated code
        ↓
Human reads it
        ↓
Human tries to understand intent
        ↓
Human guesses whether assumptions are correct
        ↓
Tests
```

There is a lot of interpretation involved.

Now consider a system with explicit contracts:

```
AI generated code
        ↓
TypeScript compiler
        ↓
Schema validation
        ↓
API contract tests
        ↓
Architecture rules
        ↓
State-machine tests
        ↓
Database constraints
        ↓
Workflow verification
```

The AI still generates code, but it generates code inside a much smaller space of possibilities.

That is the real advantage.

We don't need AI to become perfectly reliable. We can make the environment around AI more verifiable.

These tools don't only prevent mistakes. They improve the context available to the AI.

Suppose I ask an AI:

Add a new payment provider.

In an implicit codebase, the AI has to discover how payments work, where providers live, which errors are possible, how payment state is represented, how retries work, which modules can depend on which, which API contract is expected, and how the database represents payments.

It has to reconstruct all of that.

Now imagine the same request in an explicit system. The AI can inspect the payment state machine, payment schema, payment API contract, dependency rules, database schema, and workflow definition.

The problem has become much smaller.

The AI isn't necessarily smarter. The system is simply giving it better information.

This is why I increasingly think about constraints as **context compression**.

An explicit model can communicate a large amount of intent in a small, machine-readable representation.

When something is implicit, testing often requires testing the implementation.

When something is explicit, we can often test the model.

With an FSM we can test:

```
Can Submitted → Purchased happen directly?
```

With an API contract:

```
Does POST /orders return 201 with an Order?
```

With a schema:

```
Does invalid email data get rejected?
```

With dependency rules:

``` python
Can domain import infrastructure?
```

It should not.

With a database schema:

```
Can an Order reference a non-existent User?
```

It should not.

With Temporal:

```
What happens when Activity #2 fails?
```

With OpenAPI:

```
Does the implementation conform to the published API?
```

The verification target moves from "Does this code look correct?" toward "Does this implementation satisfy these explicit constraints?"

That is a much better problem for automation.

There is an obvious danger here.

If explicitness is good, it is tempting to make everything explicit.

That would be a mistake.

Not every function needs an effect system. Not every application needs a state machine. Not every database needs an ORM. Not every project needs five architecture enforcement tools.

The goal is not maximum constraint. The goal is to make **important assumptions explicit**.

A useful question is:

If this assumption were wrong, would the resulting bug be expensive?

If the answer is yes, it is a good candidate for explicit representation.

If the answer is no, inference may be perfectly reasonable.

The objective is not rigidity. It is explicitness where explicitness creates value.

This is what I find interesting about the current TypeScript ecosystem.

It is no longer just a language with a type checker. There are tools for making almost every important boundary explicit:

```
             Business process
                    │
                 XState
                    │
              Application
                    │
          ┌─────────┴─────────┐
          │                   │
       Effect              fp-ts
          │                   │
          └─────────┬─────────┘
                    │
                TypeScript
                    │
       ┌────────────┼────────────┐
       │            │            │
     Zod         tRPC        OpenAPI
       │            │            │
       └────────────┼────────────┘
                    │
              Database
                    │
       Prisma / Drizzle / Kysely
                    │
              Infrastructure
                    │
             Pulumi / CDK
                    │
          Distributed Systems
                    │
      Temporal / Dapr / Actors
```

And around all of it:

```
dependency-cruiser
Madge
eslint-plugin-boundaries
```

These tools are doing something bigger than adding features to TypeScript.

They are making assumptions observable.

Before AI-assisted development, a developer could often compensate for implicitness through experience.

A senior developer might know that you shouldn't import infrastructure from the domain, that a particular API actually returns 202 rather than 200, that an operation is not safe to retry, that a state can only transition after approval, or that a database field is nullable even though the TypeScript type says otherwise.

That knowledge existed in people's heads.

AI doesn't reliably have access to that knowledge. And even when we tell it, we have to trust that it will remember and apply it consistently.

This creates a new architectural pressure:

Move important knowledge out of people's heads and into artifacts that machines can inspect and verify.

Schemas. Types. Contracts. State machines. Dependency rules. Database models. Workflow definitions. Infrastructure definitions.

These become part of the AI's context. But more importantly, they become part of the system's verification surface.

AI is making code generation increasingly cheap. That changes what we should optimize for.

We should spend less time asking:

How can I make writing this code faster?

and more time asking:

How can I make it obvious whether this code is correct?

The tools in this list answer that question in different ways. Some make data explicit. Some make behaviour explicit. Some make architecture explicit. Some make infrastructure explicit. Some make distributed execution explicit. Some make APIs explicit. And some make failure explicit.

They all reduce the amount of important information that exists only through inference.

That is valuable for humans. But I think it is becoming even more valuable for AI.

I don't think the future of software is going to be about adding more and more constraints.

It is about putting constraints in the right places.

A good system might look like this:

```
AI
 │
 │ intelligence
 ▼
Explicit contracts
 │
 ├── Types
 ├── Schemas
 ├── APIs
 ├── State machines
 ├── Architecture rules
 ├── Database models
 └── Workflows
 │
 ▼
Automated verification
```

The AI remains probabilistic. The system around it becomes increasingly deterministic.

That is the architectural shift I find most interesting.

We don't need to make AI deterministic. We need to stop asking AI to infer things that software can already define.

The best TypeScript tools for the AI era may therefore not be the ones that help us write code faster. They may be the ones that make the **intent behind the code impossible to misunderstand**.

Because when code generation is cheap, explicitness becomes leverage.

And when verification is the bottleneck, every explicit constraint becomes another thing a human or a machine can check.

Don't make the AI remember your rules. Make your system represent them.

That is how we move from AI-generated software to AI-generated software that we can actually verify.
