23 TypeScript Tools for Making Software Explicit in the AI Era A developer argues that AI-assisted development makes it crucial to make software assumptions explicit, and lists 23 TypeScript tools that help achieve this. The tools include Effect for explicit effects and errors, Zod for runtime data validation, and io-ts for typed data boundaries. The post emphasizes that explicit constraints are more valuable in the AI era because they reduce inference and improve verification. 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