Prototype
Quickly test design decisions before committing to an implementation. Two branches: logic (TUI) and UI (variant comparison). Always throwaway.
The Primary Decision: What Are You Testing?
Before writing a single line of prototype code, state which question you are answering. Every prototype answers exactly one of two questions:
Logic Question
“Does this logic feel right?”
You are testing whether a state machine, algorithm, or data transformation produces the right behavior. You do not care what it looks like.
Examples:
— Does this reducer handle all order state transitions?
— Does this rate limiter correctly count concurrent requests?
— Does this tree traversal find the right node?
UI Question
“What should this look like?”
You are testing whether a visual design, interaction pattern, or layout communicates clearly. You do not care about the underlying logic.
Examples:
— Should the checkout flow be a wizard or a single page?
— Does this table layout communicate pricing clearly?
— Which error message pattern is clearest to users?
Mixing questions kills prototypes
Logic Branch: State Machine / Reducer Prototype
Write the question as a single sentence at the top of the prototype file. This prevents scope creep.
// PROTOTYPE QUESTION:
// Does the order state machine correctly handle a payment failure
// followed by a retry that succeeds?
//
// THROWAWAY: delete or absorb after answer is confirmed.
// DO NOT use this file as the basis for the production implementation.For logic prototypes, choose the language that minimizes ceremony and maximizes clarity of the logic being tested:
| Language | Best for | Avoid when |
|---|---|---|
| TypeScript | State machines with complex types, reducers | Types add more code than the logic being tested |
| Python | Data transformations, algorithms, tree structures | You need to share the prototype with the frontend team |
| Plain JavaScript | Quick state machine sketches, string processing | You need type safety to catch bugs in the prototype |
The logic being tested must live in a pure function, reducer, or state machine — no side effects, no I/O, no framework dependencies. This makes it instantly testable and runnable anywhere.
- Reducer pattern: (state, event) => state. The entire state machine is one pure function.
- State machine pattern: explicit states, explicit transitions, no implicit state.
- Pure function pattern: (input) => output. No global state, no side effects.
// Reducer pattern — entire order state machine as a pure function:
type OrderState =
| { status: "pending" }
| { status: "processing"; paymentIntentId: string }
| { status: "paid"; paidAt: Date }
| { status: "failed"; reason: string; retryCount: number };
type OrderEvent =
| { type: "PAYMENT_STARTED"; paymentIntentId: string }
| { type: "PAYMENT_SUCCEEDED"; paidAt: Date }
| { type: "PAYMENT_FAILED"; reason: string }
| { type: "PAYMENT_RETRIED" };
function orderReducer(state: OrderState, event: OrderEvent): OrderState {
switch (state.status) {
case "pending":
if (event.type === "PAYMENT_STARTED") {
return { status: "processing", paymentIntentId: event.paymentIntentId };
}
break;
case "processing":
if (event.type === "PAYMENT_SUCCEEDED") {
return { status: "paid", paidAt: event.paidAt };
}
if (event.type === "PAYMENT_FAILED") {
return { status: "failed", reason: event.reason, retryCount: 0 };
}
break;
case "failed":
if (event.type === "PAYMENT_RETRIED" && state.retryCount < 3) {
return { status: "pending" };
}
break;
}
return state; // Invalid transition — return unchanged state
}A TUI runner lets you exercise the logic interactively in the terminal. It is not production code — it is a scratchpad for exploring the state machine.
- One command to run: `npx ts-node prototype.ts` or `python prototype.py`.
- Shows current state after each event.
- Accepts event names as command-line arguments for scripted testing.
- Prints the full state at each step.
// Minimal TUI runner for the order state machine:
let state: OrderState = { status: "pending" };
const events: OrderEvent[] = [
{ type: "PAYMENT_STARTED", paymentIntentId: "pi_123" },
{ type: "PAYMENT_FAILED", reason: "insufficient_funds" },
{ type: "PAYMENT_RETRIED" },
{ type: "PAYMENT_STARTED", paymentIntentId: "pi_456" },
{ type: "PAYMENT_SUCCEEDED", paidAt: new Date() },
];
console.log("Initial state:", JSON.stringify(state, null, 2));
for (const event of events) {
state = orderReducer(state, event);
console.log(`After ${event.type}:`, JSON.stringify(state, null, 2));
}
// Run: npx ts-node order-prototype.ts- Connecting the prototype to a real database or API. This adds failure modes that have nothing to do with the logic being tested.
- Adding error handling, logging, or retry logic. You are testing the happy path and the specific edge cases you identified — not building production-grade resilience.
- Using the prototype file as the starting point for the production implementation. The prototype proved the concept; the production code is written from scratch with proper structure.
- Spending more than 2 hours on a logic prototype. If you need more than 2 hours, you are building, not prototyping.
UI Branch: Visual Variant Prototype
The preferred approach is to add a ?variant= URL parameter to an existing page. This avoids creating a new route, keeps the prototype co-located with the production code, and makes deletion mechanical.
- Add a ?variant= query parameter to the existing page URL.
- Render a different layout when the parameter is present.
- Share the URL with stakeholders for feedback.
- Delete the variant condition after a decision is made.
// app/checkout/page.tsx — variant added inline
export default function CheckoutPage({
searchParams,
}: {
searchParams: { variant?: string };
}) {
const variant = searchParams.variant;
if (variant === "wizard") {
return <CheckoutWizardVariant />;
}
if (variant === "single-page") {
return <CheckoutSinglePageVariant />;
}
if (variant === "sidebar") {
return <CheckoutSidebarVariant />;
}
// Default (production) layout
return <CheckoutDefault />;
}
// PROTOTYPE NOTE: remove variant routing after decision is made.
// Chosen variant becomes the new default. Others are deleted.Create a new route only when modifying an existing page is not feasible — for example, when prototyping a completely new page that does not yet exist, or when the existing page has too much production complexity to safely add a variant condition.
- Create the new route under /prototype/[name]/ to clearly mark it as non-production.
- Add a banner or badge to the prototype page marking it as a prototype.
- Delete the route after the decision is made.
- Never link to a /prototype/* route from any production page.
// app/prototype/checkout-redesign/page.tsx
// PROTOTYPE ONLY — delete after design decision is made
// Created: 2025-06-01 | Ticket: #1234
export default function CheckoutPrototype() {
return (
<div>
{/* Prototype banner */}
<div className="bg-yellow-100 border-b border-yellow-200 px-4 py-2 text-sm text-yellow-800">
PROTOTYPE — not for production use
</div>
{/* Prototype content */}
</div>
);
}The point of a UI prototype is to discover what you do not know about the design space. This requires genuine exploration, not minor variations on one approach.
What 'radically different' means
- Variant A: optimize for first-time users who need guidance.
- Variant B: optimize for returning users who need speed.
- Variant C: optimize for mobile users on slow connections.
- Do not evaluate the variants yourself. Share them and collect feedback.
Add a floating bottom bar that lets stakeholders switch between variants without editing the URL manually. This dramatically reduces friction in feedback sessions.
// components/variant-switcher.tsx
// PROTOTYPE UTILITY — delete with the prototype
"use client";
import { useRouter, useSearchParams } from "next/navigation";
const variants = [
{ key: "default", label: "Default" },
{ key: "wizard", label: "Wizard" },
{ key: "single-page", label: "Single Page" },
{ key: "sidebar", label: "Sidebar" },
];
export function VariantSwitcher() {
const router = useRouter();
const searchParams = useSearchParams();
const current = searchParams.get("variant") ?? "default";
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-white border border-gray-200 rounded-full shadow-lg px-4 py-2 flex gap-2 z-50">
<span className="text-xs text-gray-500 font-medium self-center mr-1">Variant:</span>
{variants.map((v) => (
<button
key={v.key}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
if (v.key === "default") {
params.delete("variant");
} else {
params.set("variant", v.key);
}
router.push("?" + params.toString());
}}
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
current === v.key
? "bg-gray-900 text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
{v.label}
</button>
))}
</div>
);
}- Adding persistence to a UI prototype. The prototype tests layout and interaction, not data management.
- Polish. Typography tuning, pixel-perfect spacing, custom animations. These belong after the design decision is made.
- Feature completeness. Every variant should be visually complete for the question being tested, but should not implement adjacent features.
- Using real user data in prototypes without consent. Use realistic fake data instead.
Universal Prototype Rules
| Rule | Logic branch | UI branch |
|---|---|---|
| Throwaway | Delete or absorb into production code from scratch after decision | Delete variant condition and unused variants after decision |
| One command to run | npx ts-node prototype.ts or python prototype.py | npm run dev, then navigate to ?variant=X |
| No persistence | No database calls, no file writes | No form submissions, no API calls |
| Skip polish | No error handling, no logging, no retry | No custom animations, no pixel-perfect spacing |
| Surface state | Print the full state at each step | Show which variant is active in the floating bar |
| Delete or absorb | If the prototype works, build the real thing. Do not ship the prototype. | If a variant wins, rebuild it cleanly as the default. Do not promote the prototype. |
Never ship a prototype