Coding agents can produce plausible implementation faster than a reviewer can reconstruct every hidden assumption. Review gets easier when each mistake has an obvious place to be caught and an obvious repair. One tool cannot cover invalid internal states, malformed external data, expected domain failure, and laws that break only across many inputs.
Each failure needs a different safeguard. The TypeScript compiler checks the states the program owns. Zod is a runtime schema validator for values arriving from outside the program. Result<T, E> is a data type that makes an expected error part of a function's return value. Property-test generators try the same law across many inputs. Together these layers make assumptions visible to agents and reviewers, but they still cannot prove that the product policy is right.


Map each failure to one layer
Shared conventions can make this split visible to both agents and reviewers 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 still 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. A final check after a switch can also turn every unhandled status into a compiler error when a new state is added. 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
Hraness’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 the random starting value needed to replay a run, and searches for a smaller input that still fails.
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.