Reducing the Solution Space
Using TypeScript and explicit domain models to keep architectural control while delegating implementation to coding agents.
At this point everyone knows that 'vibecoding' works. And it works until it doesn't. I'm using 'vibecoding' in a more limited sense here than all agent-assisted software development. I'm talking about the kind of development where the originator of the idea doesn't engage with the code at all. Not on an architectural level, not on a function level.
But I also don't think it's a binary. Somewhere on the spectrum, you might not want to write each line by hand. But you still have a clear idea about data, abstractions, and general architecture. What you need then is a way to make sure your agentic sessions adhere to that.
Because there is more than one way to skin a cat. And the second you leave something ambiguous, your agent might pick an approach that goes against what you envisioned. Sometimes that doesn't matter. Sometimes it does. And sometimes it does, but it doesn't show up until way later, when the code has drifted from the original architectural idea.
Good constraints make bad solutions harder to express
There are a lot of ways to solve almost any engineering problem. Most of them have tradeoffs. And every design decision I leave unspecified is a decision the agent has to make.
It might make a perfectly reasonable choice. But that choice becomes something the next task builds on. And then the next. Eventually, changing it means changing quite a lot of other things too.
Good constraints make bad solutions harder to express.
And here is the thing. Prompts and cleartext design docs that keep the agent on track work, at least to a degree. They really do. Especially when each task is small, bounded, authored, and reviewed.
But as you delegate more, to get more efficient, there are more opportunities for those instructions to be missed or interpreted differently. And it might break in subtle ways you don't see right away. Because the solution worked. It just wasn't the right solution for the system you were building.
A loose codebase gives you a lot of room to keep making the current approach work. Another optional field. Another special case. A fallback for the thing that should always be there. The feature works, so the task looks finished. But the structure gets a little harder to reason about each time.
Programmatic constraints
Linters and other programmatic checkers can do some of this work for you. A lot of what we encounter day to day is about style and consistency. But lint rules can also restrict imports and enforce boundaries between modules. Those are architectural decisions too.
The useful part is that you can check whether a rule was followed. It doesn't depend entirely on someone remembering a paragraph in a design document.
And this is where I think TypeScript gets interesting. Because you can put quite a lot of your design into the shapes the code is allowed to work with.
TypeScript as a constraint tool
TypeScript, on its own, still gives you plenty of freedom. You can reach for any, use plain strings for concepts that mean different things, and make fields optional whenever they become inconvenient. But you can also put quite a lot of your design into the types. It depends on what you ask them to preserve.
Take a service that delivers messages by email or SMS. A loose model could look like this:
type Delivery = {
kind: string;
email?: string;
phoneNumber?: string;
subject?: string;
body: string;
};
That allows an email delivery without an email address. Or an SMS delivery without a phone number. Now every function using it has to decide what those situations mean. Skip it? Supply a default? Throw an error?
But those were never meant to be situations in the first place.
We can express the requirements directly:
type Delivery =
| { kind: "email"; email: string; subject: string; body: string }
| { kind: "sms"; phoneNumber: string; body: string };
Now each kind requires the data it needs. When the code checks that a delivery is an email, TypeScript knows there is an email address and a subject. It doesn't know whether the address is valid. That still needs validation at the boundary. But the implementation has fewer missing-data cases to invent behavior for.
And that is the kind of strictness I care about. The types carry a decision about what a valid delivery needs to contain.
A few compiler settings
The compiler needs to participate in this too. These constraints need to be checked as the code changes. Editor feedback helps while you're working. A type-checking step in the build makes unresolved errors block that build.
For this kind of approach, a starting point is:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
}
These settings do different jobs. The last three are separate options; turning on strict doesn't enable them.
strict enables a group of checks, including checking for missing values and rejecting implicitly inferred any in places where TypeScript needs more information. It doesn't stop you from explicitly writing any. You can still choose to give up the information you just worked to define.
noUncheckedIndexedAccess makes ordinary array indexing and lookups through index signatures account for a missing value. recipients[0] might not exist. If an operation needs at least one recipient, that is something the code has to deal with, or the types need to express a stronger guarantee.
exactOptionalPropertyTypes distinguishes between leaving an optional property out and explicitly assigning undefined to it. If both are allowed, the type needs to say so. That matters when the presence of a field itself carries meaning, like in an update object.
noImplicitOverride is useful when you use class inheritance. It requires an explicit override when a member overrides something in the base class. That makes the relationship visible, and helps catch it when the base class changes.
None of these settings chooses your architecture for you. They make certain assumptions harder to leave unchecked. You still have to decide what the data means.
Typing patterns that narrow the choices
The delivery example is a discriminated union. A small detail, the kind field, tells the compiler which shape it is dealing with. That lets you describe the valid combinations directly, instead of making every consumer sort through a pile of optional properties.
You can then use an exhaustive check when handling those variants. For example, after handling every delivery kind in a switch, you can require that the remaining value is never. If you later add push notifications and leave that handler unchanged, it becomes a compiler error. A broad default branch returning an empty result could quietly hide the missing behavior.
An explicit return type can also help here. With strict null checking, a function declared to return a result that excludes undefined cannot fall through that switch and return nothing. The important part is having a check that makes the missing case visible. TypeScript's exhaustiveness examples show both approaches.
Branded IDs address another kind of ambiguity. A user ID and an order ID might both be strings at runtime. But they represent different things. A plain alias like type UserId = string doesn't distinguish them. A brand adds a type-level marker so that passing an order ID where a user ID belongs is rejected. You still need a deliberate place to create or validate those values. The brand itself doesn't validate incoming data.
Another useful habit is to derive types from the model you already have. And use satisfies to check the values that depend on it:
type DeliveryKind = Delivery["kind"];
const deliveryLabels = {
email: "Email",
sms: "Text message",
} as const satisfies Record<DeliveryKind, string>;
DeliveryKind gets its alternatives from Delivery. There is no second list of strings to keep in sync. satisfies checks that the labels cover those alternatives, while the object retains its specific keys. Here, as const also preserves the literal label values.
Add a push delivery to the union, and this map needs a push label before it passes the check. The compiler points to a consequence of the change that the agent might otherwise have missed.
These are different ways of making distinctions matter to the compiler. Which delivery is this? Which ID does this function accept? Which entries does this configuration need?
Of course, you can undo a lot of this with any, broad assertions, or types that keep getting loosened until the code compiles. And an agent can do that too. A compiler error should be a reason to look at the disagreement between the model and the implementation. Sometimes the model needs to change. But weakening it just to get past the error gives you back the ambiguity you were trying to remove.
Typing before implementation
The useful part is making some of these decisions before there is a large implementation to work around. What information has to exist? Which states are valid? What should a function produce for the next part of the system to consume?
I tried this in DailyDragon, an RPG project, before handing over a lot of the implementation. One of the early decisions was that abilities would produce a shared set of effects. At that point, the contract looked like this:
resolve: (source: Combatant, targets: Combatant[]) => PrimitiveEffect[];
An ability returned effects. Attacks, heals, applying a status, removing a status. Each effect carried a source and a target. Those effects could then go through a common pipeline for modifiers before being applied to the combat state.
Think about adding a fireball. You could make it subtract health directly, and for a while that might be all you need. But then you add resistance. Or a status that increases outgoing damage. Or something that changes the element of an attack.
If each ability has its own way of handling those things, every new mechanic gives you more places to keep consistent. The effect model gives those interactions a common place to happen. And the ability's return type gives the agent an existing structure to work within.
The larger implementation followed. And the types continued to evolve. Later, adding action points meant every ability needed a required speedCost. Adding that field to the type made a missing cost a compiler error wherever an ability was constructed. Player abilities, enemy abilities, and definitions outside the main catalog all had to supply it.
That is useful well beyond games. When a new requirement applies to every implementation of a contract, making it required lets type checking identify the places that need attention. Making it optional and quietly supplying a default can leave the old assumptions in place. Sometimes a default is the right decision. But it should be a decision.
And there are limits. That resolve signature requires a particular return type, but it doesn't prevent the function from also mutating its arguments. The architecture still needs explanation, and the implementation still needs review.
Constraints can preserve a bad decision too. So I want to make the important choices explicit, then change them deliberately when the implementation teaches me something. I can do that one part of the system at a time.
That's the habit I want to keep as I delegate more. Define the model, give the implementation a structure to fit into, and use the compiler errors to find where the two disagree. Spend more of the review on whether those decisions still make sense.
There is still more than one way to skin a cat. I just want to make some of those choices before the agent makes them for me.