DEV Community

Remo H. Jansen
Remo H. Jansen

Posted on

23 TypeScript Tools for Making Software Explicit in the AI Era

In my previous articles, I argued that AI is changing the role of constraints in software development.

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.

Schemas and Side Effects

1. Effect

Effect makes effects, errors, dependencies, concurrency, resources, and schemas explicit.

Without Effect, we might write:

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

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

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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.

2. Zod

Zod makes runtime data validation explicit.

Without it:

const user = await response.json();

sendEmail(user.email);
Enter fullscreen mode Exit fullscreen mode

The programmer is implicitly assuming that the response contains an email property.

With Zod:

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

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

sendEmail(user.email);
Enter fullscreen mode Exit fullscreen mode

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.

3. io-ts

io-ts makes the boundary between unknown runtime data and typed data explicit.

Without a codec:

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

const payment: Payment = await getPayment();
Enter fullscreen mode Exit fullscreen mode

The type says payment is valid, but the runtime data has not actually been checked.

With io-ts:

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

const result = Payment.decode(await getPayment());
Enter fullscreen mode Exit fullscreen mode

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

4. Valibot

Valibot makes runtime validation and schemas explicit with a lightweight API.

Without it:

function createAccount(input: any) {
  // Assume input is valid.
}
Enter fullscreen mode Exit fullscreen mode

With Valibot:

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);
}
Enter fullscreen mode Exit fullscreen mode

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

5. TypeBox

TypeBox makes JSON Schema and TypeScript type definitions explicit and connected.

Without it:

interface CreateOrder {
  productId: string;
  quantity: number;
}
Enter fullscreen mode Exit fullscreen mode

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

With TypeBox:

const CreateOrder = Type.Object({
  productId: Type.String(),
  quantity: Type.Integer({ minimum: 1 })
});
Enter fullscreen mode Exit fullscreen mode

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.

State and Behaviour

6. XState

XState makes states, events, transitions, and actors explicit.

Without a state machine:

if (loading) {
  // ...
}

if (error) {
  // ...
}

if (data) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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

With XState:

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

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

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

    success: {},
    failure: {
      on: { RETRY: "submitting" }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

Data and Persistence

7. Prisma

Prisma makes database models, relationships, migrations, and generated data access types explicit.

Without a schema-first ORM:

const users = await db.query(
  "SELECT * FROM users WHERE organisation_id = ?",
  [organisationId]
);
Enter fullscreen mode Exit fullscreen mode

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[]
}
Enter fullscreen mode Exit fullscreen mode

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

8. Drizzle

Drizzle ORM makes database schemas, SQL relationships, and query types explicit in TypeScript.

Without it:

const result = await db.query(
  "SELECT id, name FROM users WHERE active = true"
);
Enter fullscreen mode Exit fullscreen mode

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

With Drizzle:

const users = await db
  .select({
    id: usersTable.id,
    name: usersTable.name
  })
  .from(usersTable)
  .where(eq(usersTable.active, true));
Enter fullscreen mode Exit fullscreen mode

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

9. Kysely

Kysely makes SQL queries and their result types compile-time checked.

Without it:

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

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

With Kysely:

const result = await db
  .selectFrom("user")
  .select(["id", "email"])
  .where("organisation_id", "=", organisationId)
  .execute();
Enter fullscreen mode Exit fullscreen mode

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.

Infrastructure

10. Pulumi

Pulumi 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:

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

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

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.

11. AWS CDK

AWS 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:

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));
Enter fullscreen mode Exit fullscreen mode

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

Architecture

12. dependency-cruiser

dependency-cruiser makes module dependency rules explicit and enforceable.

Imagine:

UI
 ↓
Application
 ↓
Domain
 ↓
Infrastructure
Enter fullscreen mode Exit fullscreen mode

But nothing prevents:

Domain
 ↓
Infrastructure
Enter fullscreen mode Exit fullscreen mode

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.

13. Madge

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
Enter fullscreen mode Exit fullscreen mode

With Madge:

madge --circular src/
Enter fullscreen mode Exit fullscreen mode

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

14. eslint-plugin-boundaries

eslint-plugin-boundaries makes architectural layer and module boundaries explicit and enforceable.

Without it:

import { UserRepository } from "../../infrastructure/database";
Enter fullscreen mode Exit fullscreen mode

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 ❌
Enter fullscreen mode Exit fullscreen mode

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

APIs

15. tRPC

tRPC makes client/server procedure contracts explicit and type-safe.

Without a shared contract:

api.post("/users", {
  name,
  email
});
Enter fullscreen mode Exit fullscreen mode

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

With tRPC:

const createUser = publicProcedure
  .input(
    z.object({
      name: z.string(),
      email: z.string().email()
    })
  )
  .mutation(({ input }) => {
    // ...
  });
Enter fullscreen mode Exit fullscreen mode

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.

16. ts-rest

ts-rest makes HTTP endpoints, parameters, payloads, and responses explicit as shared contracts.

Without it:

fetch("/api/orders", {
  method: "POST",
  body: JSON.stringify(order)
});
Enter fullscreen mode Exit fullscreen mode

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

With a contract:

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

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

17. oRPC

oRPC makes RPC procedures and their client/server contracts explicit and type-safe.

Without a contract:

client.createUser(...)
Enter fullscreen mode Exit fullscreen mode

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.

18. OpenAPI

OpenAPI Initiative 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.
Enter fullscreen mode Exit fullscreen mode

With OpenAPI:

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

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

19. Orval

Orval makes OpenAPI contracts executable by generating strongly typed clients.

Without generation, an AI might repeatedly write:

fetch("/api/users/123")
Enter fullscreen mode Exit fullscreen mode

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.

Distributed Systems

20. Proto.Actor

Proto.Actor makes actors, messages, supervision, identity, and distributed communication explicit.

Without an actor model:

await sendMessage(node, message);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

21. Dapr

Dapr 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);
Enter fullscreen mode Exit fullscreen mode

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.

22. Temporal

Temporal makes durable workflows, activities, retries, timers, failures, and long-running execution explicit.

Without Temporal:

await chargeCard();
await createOrder();
await sendEmail();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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.

Functional Programming

23. fp-ts

fp-ts makes functional effects, optionality, errors, and composition explicit through types.

Without it:

function findUser(id: string): User | undefined {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The caller has to remember to handle the missing case.

With explicit functional structures:

const findUser = (id: string): Option<User> => {
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Or:

const createUser = (
  input: CreateUser
): Either<ValidationError, User> => {
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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.

The Common Pattern

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.

Explicitness Creates a Verification Surface

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Better Context Produces Better Code

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.

Explicitness Also Changes Testing

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?
Enter fullscreen mode Exit fullscreen mode

With an API contract:

Does POST /orders return 201 with an Order?
Enter fullscreen mode Exit fullscreen mode

With a schema:

Does invalid email data get rejected?
Enter fullscreen mode Exit fullscreen mode

With dependency rules:

Can domain import infrastructure?
Enter fullscreen mode Exit fullscreen mode

It should not.

With a database schema:

Can an Order reference a non-existent User?
Enter fullscreen mode Exit fullscreen mode

It should not.

With Temporal:

What happens when Activity #2 fails?
Enter fullscreen mode Exit fullscreen mode

With OpenAPI:

Does the implementation conform to the published API?
Enter fullscreen mode Exit fullscreen mode

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.

This Does Not Mean Everything Should Be Explicit

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.

The TypeScript Ecosystem Is Becoming an Explicitness Ecosystem

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
Enter fullscreen mode Exit fullscreen mode

And around all of it:

dependency-cruiser
Madge
eslint-plugin-boundaries
Enter fullscreen mode Exit fullscreen mode

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

They are making assumptions observable.

AI Makes This More Important

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.

From Code Generation to Code Verification

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.

The Future Is Not More Constraints

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)