Agent-written code raises output volume, so review needs a stable way to classify mistakes. One tool cannot cover invalid internal states, malformed external data, expected domain failure, and laws that break only across many inputs.
Use TypeScript for owned states, Zod at foreign boundaries, Result<T, E> for a success value of type T or an expected error of type E, and property tests for laws over large input spaces. Each layer handles a different kind of mistake. Together they still cannot prove that the product policy is right.


Map each failure to one layer
Shared packages can make this split visible without taking policy away from the product. The schema package parses foreign values, the Result package represents expected failure, and the test package supplies generators and helpers. Product code owns its states and decisions.
packages/
├── schema/
│ └── index.ts # Zod schemas and parse helpers
├── result/
│ └── index.ts # Result<T, E> and combinators
└── test/
├── arbitraries.ts # shared fast-check generators
└── setup.ts
projects/example/src/
├── domain.ts # product-owned states and policy
├── boundary.ts # unknown input enters here
└── domain.property.test.ts # laws beside the code they protectThe shared layer owns conventions, not business rules. An agent can find the boundary, failure, and testing tools without learning a local framework, while the product still expresses its states directly.
Types make invalid states harder to express
TypeScript adds a compiler check to JavaScript source, then removes the type syntax when it emits JavaScript. TypeScript’s structural type system compares the members a value has rather than requiring declared ancestry. To support common JavaScript patterns, it also accepts some operations that can still fail at runtime. A clean compile filters relationships in owned code; it cannot validate live JSON or a business decision.
A discriminated union can say that a task is exactly one of "queued", "running", "complete", or "failed". Each state then carries only its valid fields.
type Task =
| { id: string; status: "queued" }
| { id: string; status: "running"; startedAt: Date }
| { id: string; status: "complete"; output: string }
| { id: string; status: "failed"; error: string };In checked code, the union rejects a completed task with no output. When a switch uses an exhaustiveness guard, a new status also becomes a compile error at every unhandled decision point. TypeScript still trusts assertions and unchecked inputs, so foreign values need a runtime check before they enter this model.
Zod parses values at foreign boundaries
Treat API responses, stored values, environment variables, and model outputs as unknown until a runtime check accepts them. Zod is a runtime schema-validation library. A schema checks the observed value and infers the matching TypeScript type. safeParse returns either parsed data or a validation error, so the boundary needs no unchecked assertion. A successful parse proves only that the value matches the schema. Parse when data crosses a trust boundary; parsing every object already owned by the program adds work without a new guarantee.
Result keeps expected failure in the return type
CCLRTE’s open-source Result package provides this two-case type. Result<T, E> has two cases: ok: true with a value of type T, or ok: false with an error of type E. In checked code, the caller must inspect that distinction before the success value is available.
Return a Result when failure is expected and the caller can recover, report, retry, or choose a fallback. Plain absence can remain null, and a broken invariant can still throw. Result makes a normal failure visible in the function contract; it cannot choose the recovery policy.
const ReplySchema = z.object({
answer: z.string(),
confidence: z.number().min(0).max(1),
});
type Reply = z.infer<typeof ReplySchema>;
function parseReply(input: unknown): Result<Reply, z.ZodError> {
const parsed = ReplySchema.safeParse(input);
return parsed.success
? { ok: true, value: parsed.data }
: { ok: false, error: parsed.error };
}The schema owns the acceptance rule. Result carries rejection into the caller. Neither layer decides whether a low-confidence reply is useful; that remains product policy.
Property tests challenge laws across generated inputs
An example test checks one selected case. A property test states a law and generates inputs to challenge it. QuickCheck’s 2000 paper popularized that loop. fast-check brings the model to TypeScript, records seeds for replay, and shrinks a failure to a smaller counterexample.
Use a property when the behavior can be stated as a law across many inputs: a parser handles arbitrary JSON without throwing, a valid value survives a round trip, provider order cannot change a selection, or a retry loop never exceeds its budget. Keep named examples for cases whose exact story matters.
fc.assert(
fc.property(fc.jsonValue(), (input) => {
const result = parseReply(input);
if (!result.ok) {
return result.error instanceof z.ZodError;
}
return typeof result.value.answer === "string"
&& result.value.confidence >= 0
&& result.value.confidence <= 1;
}),
);This property challenges the parser with generated JSON and fails if the parser throws, returns the wrong error class, or accepts a value outside the declared shape. The run searches a finite sample. Its value depends on the generator and the property; it is not a proof.
Adopt the layer that matches the failure
Use TypeScript whenever the program owns the state. Add Zod where data crosses from an API, storage, configuration, or model into owned code. Return a Result when rejection is expected and the caller has a recovery choice. Add property tests when a rule spans more input combinations than a short example table can cover.
A small internal function with two obvious cases may need only types and example tests. The full stack earns its cost when a real boundary, expected failure, or combinatorial law exists. These four layers make those mistakes easier to find; integration tests and product review still decide whether the system does the right work.