Render Result Monads
Render Result Monads - Design Log 54
Written for AI agents. See Log Methodology Note below for details.
Background
The Jay Stack framework uses several result types for server-side rendering phases:
ServerError5xx- Server errors (database down, internal errors)ClientError4xx- Client errors (not found, unauthorized)Redirect3xx- Redirects (moved, temporary redirect)PartialRender<ViewState, CarryForward>- Successful partial render with data
Currently, these types are simple discriminated unions that are returned directly from render functions. The challenge is that error handling requires explicit if/else or switch statements, making the code verbose and error-prone.
Current Usage Pattern
async function renderSlowlyChanging(props: PageProps): Promise<SlowlyRenderResult<...>> {
const product = await getProductBySlug(props.slug);
if (!product) return notFound(); // Must check and return early
const { id, name, description } = product;
return partialRender(
{ id, name, description },
{ productId: id }
);
}
Pain Points
- Verbose Error Handling: Every potential failure requires explicit checks
- No Message Support: Error types don't carry human-readable messages or error details
- Chaining is Manual: Multiple operations require nested conditionals
- Try-Catch Wrapping: API/database calls need manual try-catch blocks
Goals
- Functional Composition: Enable
map,flatMap(chain) operations on results - Error Short-Circuiting: Errors should automatically propagate through the chain
- Async Support: Mapping functions can be async
- Message/Details: Add support for error messages, codes, and details
- Type Safety: Maintain full TypeScript type inference throughout the chain
- Ergonomic API: Make common patterns simple and readable
Design Questions & Answers
Q1: What should the monad type be called?
The monad is not the result itself - it's a pipeline/chain that produces the result. So naming it RenderResult would be confusing:
// Confusing: "RenderResult" is not what we're returning
return RenderResult.from(...).map(...).toPhaseOutput();
Options:
RenderPipeline<T>- Emphasizes the pipeline/flow nature ✓PhaseBuilder<T>- Emphasizes building toward a phase output ✓PhaseChain<T>- Chain that produces phase output ✓Render<T>- Short, describes the render operation in progressPipeline<T>- Generic but clear about purposeDataPipeline<T>- Generic data transformation pipeline
Recommendation: RenderPipeline<T> - It clearly communicates:
- It's about rendering (domain-specific)
- It's a pipeline (operations flow through it)
- The result comes out at the end via
.toPhaseOutput()
// Clear: A pipeline that produces phase output
return RenderPipeline.for<ProductSlowViewState, CarryForward>()
.from(() => getProduct(id))
.map(...)
.toPhaseOutput();
Q2: Should we rename PartialRender to something else?
The current name "PartialRender" can be confusing because:
- It implies the render is incomplete (but it's actually complete for that phase)
- It's tied to the multi-phase rendering concept (slow/fast/interactive)
- The "partial" refers to the ViewState being partial, not the render
Options:
PhaseRender- Indicates a phase was renderedRenderSuccess- Generic success indicatorRendered- Simple past-tensePhaseOutput- Describes the output of a phaseOk- Rust-inspired, pairs with error typesSuccess- Clear success indication
Recommendation: PhaseOutput<ViewState, CarryForward> - It's accurate (output of a rendering phase) and doesn't have the "partial" confusion.
Q3: What message/details structure should error types have?
Option A: Simple Message
interface ClientError4xx {
kind: 'ClientError';
status: number;
message?: string;
}
Option B: Message + Error Code
interface ClientError4xx {
kind: 'ClientError';
status: number;
message?: string;
code?: string; // e.g., 'PRODUCT_NOT_FOUND', 'AUTH_EXPIRED'
}
Option C: Full Details Object
interface ClientError4xx {
kind: 'ClientError';
status: number;
message?: string;
code?: string;
details?: Record<string, unknown>; // Arbitrary additional data
}
Recommendation: Option C with details - It provides flexibility for logging, debugging, and error pages without being overly prescriptive.
Q4: How should async mapping work?
The monad should support async operations seamlessly:
// Desired API
RenderPipeline.for<ViewState, CarryForward>()
.tryAsync(() => getProductBySlug(slug))
.then((p) =>
p
.mapAsync((product) => fetchInventory(product.inventoryId))
.map((inventory) => ({ inStock: inventory.count > 0 }))
.toPhaseOutput(),
);
Implementation Approach:
map<U>(fn: (value: T) => U): RenderPipeline<U>- Sync mapmapAsync<U>(fn: (value: T) => Promise<U>): Promise<RenderPipeline<U>>- Async mapflatMap<U>(fn: (value: T) => RenderPipeline<U>): RenderPipeline<U>- Chain pipelinesflatMapAsync<U>(fn: (value: T) => Promise<RenderPipeline<U>>): Promise<RenderPipeline<U>>- Async chain
Q5: Should we use a class or type + functions approach?
Option A: Class-based (OOP)
class RenderPipeline<T> {
private constructor(private readonly value: PipelineValue<T>) {}
static ok<T>(value: T): RenderPipeline<T>;
static notFound(message?: string): RenderPipeline<never>;
map<U>(fn: (value: T) => U): RenderPipeline<U>;
flatMap<U>(fn: (value: T) => RenderPipeline<U>): RenderPipeline<U>;
}
Option B: Type + Functions (FP)
type RenderPipeline<T> = PhaseOutput<T> | ClientError4xx | ServerError5xx | Redirect3xx;
function map<T, U>(pipeline: RenderPipeline<T>, fn: (value: T) => U): RenderPipeline<U>;
function flatMap<T, U>(
pipeline: RenderPipeline<T>,
fn: (value: T) => RenderPipeline<U>,
): RenderPipeline<U>;
Option C: Hybrid (Type + Builder)
type PipelineValue<T> = PhaseOutput<T, any> | ClientError4xx | ServerError5xx | Redirect3xx;
interface RenderPipeline<T, CarryForward = unknown> {
readonly value: PipelineValue<T>;
map<U>(fn: (value: T) => U): RenderPipeline<U, CarryForward>;
flatMap<U>(fn: (value: T) => RenderPipeline<U, CarryForward>): RenderPipeline<U, CarryForward>;
}
Recommendation: Option A (Class-based) - It provides:
- Better method chaining ergonomics
- Clear distinction between the pipeline and its output values
- Encapsulation of error handling logic
- Better TypeScript inference in chains
- Natural home for the
.for<VS, CF>()typed factory method
Q6: How should the monad integrate with existing SlowlyRenderResult and FastRenderResult?
The current types are:
type SlowlyRenderResult<VS, CF> =
PartialRender<VS, CF> | ServerError5xx | ClientError4xx | Redirect3xx;
type FastRenderResult<VS, CF> =
PartialRender<VS, CF> | ServerError5xx | ClientError4xx | Redirect3xx;
After renaming PartialRender to PhaseOutput:
type SlowlyRenderResult<VS, CF> =
PhaseOutput<VS, CF> | ServerError5xx | ClientError4xx | Redirect3xx;
type FastRenderResult<VS, CF> = PhaseOutput<VS, CF> | ServerError5xx | ClientError4xx | Redirect3xx;
Approach:
RenderPipeline<T>is the monadic wrapper used during computation- Convert to
SlowlyRenderResult/FastRenderResultat the end with.toPhaseOutput() - These result types become the "final types" as requested
- The union type
RenderOutcome<VS, CF>is identical toSlowlyRenderResult/FastRenderResult
// Usage
async function renderSlowlyChanging(props: PageProps): Promise<SlowlyRenderResult<SlowVS, CF>> {
return RenderPipeline.for<SlowVS, CF>()
.tryAsync(() => getProductBySlug(props.slug))
.then(
(p) =>
p
.map((product) => ({ id: product.id, name: product.name }))
.withCarryForward({ productId: product.id })
.toPhaseOutput(), // Returns RenderOutcome<SlowVS, CF> which equals SlowlyRenderResult<SlowVS, CF>
);
}
Q7: Should we support catching exceptions automatically?
Option A: Explicit Error Handling
const pipeline = await RenderPipeline.tryAsync(() => database.getProduct(id)).catch((err) =>
RenderPipeline.serverError(500, 'Database error'),
);
Option B: Auto-Catch with Recovery
const pipeline = await RenderPipeline.tryAsync(() => database.getProduct(id));
return pipeline
.recover((err) => {
if (err instanceof NotFoundError) return RenderPipeline.notFound();
if (err instanceof AuthError) return RenderPipeline.unauthorized();
return RenderPipeline.serverError(500, err.message);
})
.toPhaseOutput();
Option C: Configurable Error Handler
const pipeline = await RenderPipeline.tryAsync(() => database.getProduct(id), {
notFound: (err) => RenderPipeline.notFound(err.message),
default: (err) => RenderPipeline.serverError(500, err.message),
});
Recommendation: Option B (Auto-Catch with Recovery) - It's flexible and allows domain-specific error mapping.
Q8: How should ViewState and CarryForward be constructed?
Key Insight: The pipeline transforms a working value T through a chain of operations. The ViewState and CarryForward should be constructed together at the end, from the final working value.
Problem with accumulating CarryForward separately:
// This approach loses access to the working value when building carryForward
RenderPipeline.ok(product)
.withCarryForward({ productId: product.id }) // ✓ Has access to product
.map((p) => ({ name: p.name })); // T changes to { name }
// Now we've lost access to product.inventoryItemId for carryForward!
Solution: Construct both ViewState and CarryForward in .toPhaseOutput()
The .toPhaseOutput() method takes a mapping function that has access to the final working value and produces both ViewState and CarryForward:
RenderPipeline.for<ProductSlowViewState, ProductsCarryForward>()
.tryAsync(() => getProductBySlug(slug))
.map((product) => enrichProduct(product)) // T transforms freely
.toPhaseOutput((enrichedProduct) => ({
// ← Final mapping has access to everything
viewState: {
id: enrichedProduct.id,
name: enrichedProduct.name,
brand: enrichedProduct.brand,
},
carryForward: {
productId: enrichedProduct.id,
inventoryItemId: enrichedProduct.inventoryItemId,
},
}));
Q9: How do we validate output types at the start of the chain?
The Type Parameter Design:
RenderPipeline<T, TargetViewState, TargetCarryForward>;
Where:
T= Current working value being transformed throughmap()/flatMap()- can be anythingTargetViewState= The expected ViewState type for the output (set via.for<>())TargetCarryForward= The expected CarryForward type for the output (set via.for<>())
Key principle: T can transform freely through the chain. Only .toPhaseOutput(fn) validates that fn produces { viewState: TargetViewState, carryForward: TargetCarryForward }.
Usage Pattern:
return RenderPipeline.for<ProductSlowViewState, ProductsCarryForward>() // ← Set target output types
.tryAsync(() => getProductBySlug(slug)) // T = Product | null
.flatMap(
(
product, // Handle null
) => (product ? RenderPipeline.ok(product) : RenderPipeline.notFound('Product not found')),
) // T = Product
.map((product) => ({
// Enrich data
...product,
formattedPrice: formatCurrency(product.price),
})) // T = Product & { formattedPrice }
.toPhaseOutput((data) => ({
// ← Final mapping validates types
viewState: {
id: data.id,
name: data.name,
brand: data.brand,
// ✅ TypeScript validates this matches ProductSlowViewState
},
carryForward: {
productId: data.id,
inventoryItemId: data.inventoryItemId,
// ✅ TypeScript validates this matches ProductsCarryForward
},
}));
Alternative: Return PhaseOutput directly:
.toPhaseOutput(data => phaseOutput(
{ id: data.id, name: data.name }, // viewState: ProductSlowViewState
{ productId: data.id } // carryForward: ProductsCarryForward
));
Shorthand for simple cases (viewState = T, empty carryForward):
// When T already matches TargetViewState and no carryForward needed
RenderPipeline.for<SimpleViewState, {}>()
.ok(data)
.map((d) => ({ title: d.title, body: d.body }))
.toPhaseOutput(); // No function needed - T becomes viewState, {} becomes carryForward
Implementation Approach:
class RenderPipeline<T, TargetVS extends object = object, TargetCF extends object = {}> {
// Single factory method that returns entry points with types baked in
static for<TargetVS extends object, TargetCF extends object = {}>(): {
ok<T>(value: T | Promise<T>): RenderPipeline<T, TargetVS, TargetCF>;
try<T>(fn: () => T | Promise<T>): RenderPipeline<T, TargetVS, TargetCF>;
from<T>(outcome: RenderOutcome<T, any>): RenderPipeline<T, TargetVS, TargetCF>;
notFound(...): RenderPipeline<never, TargetVS, TargetCF>;
serverError(...): RenderPipeline<never, TargetVS, TargetCF>;
// ... other error constructors
};
// Unified map - always sync, handles U | Promise<U> | RenderPipeline<U>
map<U>(fn: (value: T) => U | Promise<U> | RenderPipeline<U, TargetVS, TargetCF>): RenderPipeline<U, TargetVS, TargetCF>;
// Final conversion - ONLY async method, resolves all pending promises
toPhaseOutput(
fn: (value: T) => { viewState: TargetVS; carryForward: TargetCF }
): Promise<RenderOutcome<TargetVS, TargetCF>>;
}
Q10: Why a unified map() with deferred async resolution?
Traditional functional programming has separate methods:
map(fn: T => U)- transform valuemapAsync(fn: T => Promise<U>)- async transformflatMap(fn: T => M<U>)- chain monads
Problems with separate methods:
- Cognitive overhead: Developers must choose the right method
- Refactoring pain: Adding async to a sync function requires changing
maptomapAsync - Conditional branching: Need to use
flatMapjust to return an error case - Inconsistent return types: Sometimes
RenderPipeline, sometimesPromise<RenderPipeline>
Solution: Lazy/deferred async resolution
map() is always sync - it builds up a chain of transformations. Promises are tracked internally and only resolved when toPhaseOutput() is called.
// map() always returns RenderPipeline (sync) - chains naturally
pipeline
.map(x => x * 2) // Sync - stored
.map(x => fetchDetails(x.id)) // Async - promise stored, not awaited yet
.map(x => x.valid ? x : Pipeline.notFound()) // Conditional - stored
.toPhaseOutput(...) // ← Only here promises resolve
Implementation approach:
class RenderPipeline<T, TargetVS, TargetCF> {
private constructor(
// Value can be immediate or a pending promise
private readonly _value: T | Promise<T> | ServerError5xx | ClientError4xx | Redirect3xx,
private readonly _isSuccess: boolean,
) {}
map<U>(
fn: (value: T) => U | Promise<U> | RenderPipeline<U, TargetVS, TargetCF>,
): RenderPipeline<U, TargetVS, TargetCF> {
if (!this._isSuccess) {
return this as unknown as RenderPipeline<U, TargetVS, TargetCF>; // Error passthrough
}
// Chain the transformation - may involve promises
const newValue =
this._value instanceof Promise
? this._value.then((v) => fn(v)) // Chain onto existing promise
: fn(this._value as T); // Apply to immediate value
// Normalize the result
if (newValue instanceof RenderPipeline) {
return newValue;
}
// Store promise or value - will be resolved at toPhaseOutput()
return new RenderPipeline(newValue, true);
}
async toPhaseOutput(
fn: (value: T) => { viewState: TargetVS; carryForward: TargetCF },
): Promise<RenderOutcome<TargetVS, TargetCF>> {
if (!this._isSuccess) {
return this._value as ServerError5xx | ClientError4xx | Redirect3xx;
}
// Resolve any pending promises
const resolvedValue = await this._value;
// If resolved to a RenderPipeline (from conditional map), unwrap it
if (resolvedValue instanceof RenderPipeline) {
return resolvedValue.toPhaseOutput(fn);
}
// Apply final mapping
const { viewState, carryForward } = fn(resolvedValue as T);
return { kind: 'PhaseOutput', rendered: viewState, carryForward };
}
}
Benefits:
- Consistent API:
map()always returnsRenderPipeline, chains naturally - Single async point: Only
toPhaseOutput()is async - No method selection: One
map()handles sync, async, and conditional - Cleaner code: No need to sprinkle
awaitthroughout the chain
Proposed API
Core Types
// Final result types (renamed from current)
export interface PhaseOutput<ViewState extends object, CarryForward = {}> {
kind: 'PhaseOutput';
rendered: ViewState;
carryForward: CarryForward;
}
export interface ServerError5xx {
kind: 'ServerError';
status: number;
message?: string;
code?: string;
details?: Record<string, unknown>;
}
export interface ClientError4xx {
kind: 'ClientError';
status: number;
message?: string;
code?: string;
details?: Record<string, unknown>;
}
export interface Redirect3xx {
kind: 'Redirect';
status: number;
location: string;
message?: string;
}
// Union of all possible outcomes
export type RenderOutcome<VS extends object, CF = {}> =
PhaseOutput<VS, CF> | ServerError5xx | ClientError4xx | Redirect3xx;
RenderPipeline Monad
/**
* A pipeline for composing render operations with automatic error propagation.
*
* Type Parameters:
* - T: The current working value being transformed through the chain
* - TargetVS: The expected ViewState output type (set via .for<>())
* - TargetCF: The expected CarryForward output type (set via .for<>())
*
* Usage:
* RenderPipeline.for<SlowViewState, CarryForward>()
* .tryAsync(() => fetchData())
* .map(data => transformData(data))
* .toPhaseOutput(data => ({
* viewState: { ... },
* carryForward: { ... }
* }));
*/
export class RenderPipeline<T, TargetVS extends object = object, TargetCF extends object = {}> {
private constructor(
private readonly _value: T | ServerError5xx | ClientError4xx | Redirect3xx,
private readonly _isSuccess: boolean,
) {}
// ===== Pipeline Factory =====
/**
* Create a typed pipeline with target output types declared upfront.
* TypeScript validates that .toPhaseOutput() produces these types.
*
* Returns an object with entry point methods:
* - .ok(value) - Start with a success value
* - .try(fn) - Start with a function (sync or async, catches errors)
* - .from(outcome) - Start from an existing RenderOutcome
* - .notFound(), .serverError(), etc. - Start with an error
*/
static for<TargetVS extends object, TargetCF extends object = {}>(): {
/** Start with a success value (can be T or Promise<T>) */
ok<T>(value: T | Promise<T>): RenderPipeline<T, TargetVS, TargetCF>;
/**
* Start with a function that returns T or Promise<T>.
* Catches errors into the pipeline (accessible via recover()).
* Always returns RenderPipeline (sync) - promises resolved at toPhaseOutput().
*/
try<T>(fn: () => T | Promise<T>): RenderPipeline<T, TargetVS, TargetCF>;
/** Start from an existing outcome */
from<T>(outcome: RenderOutcome<T, any>): RenderPipeline<T, TargetVS, TargetCF>;
// Error entry points
notFound(
message?: string,
details?: Record<string, unknown>,
): RenderPipeline<never, TargetVS, TargetCF>;
badRequest(
message?: string,
details?: Record<string, unknown>,
): RenderPipeline<never, TargetVS, TargetCF>;
unauthorized(
message?: string,
details?: Record<string, unknown>,
): RenderPipeline<never, TargetVS, TargetCF>;
forbidden(
message?: string,
details?: Record<string, unknown>,
): RenderPipeline<never, TargetVS, TargetCF>;
serverError(
status: number,
message?: string,
details?: Record<string, unknown>,
): RenderPipeline<never, TargetVS, TargetCF>;
clientError(
status: number,
message?: string,
details?: Record<string, unknown>,
): RenderPipeline<never, TargetVS, TargetCF>;
redirect(status: number, location: string): RenderPipeline<never, TargetVS, TargetCF>;
};
// ===== Transformation Methods =====
/**
* Transform the working value. Always returns RenderPipeline (sync).
*
* The mapping function can return:
* - U: Plain value
* - Promise<U>: Async value (resolved later at toPhaseOutput)
* - RenderPipeline<U>: For conditional errors/branching
*
* The pipeline internally tracks pending async operations.
* All promises are resolved when toPhaseOutput() is called.
*
* Examples:
* .map(x => x * 2) // Sync value
* .map(x => fetchDetails(x.id)) // Async - resolved at end
* .map(x => x.valid ? x : Pipeline.notFound()) // Conditional error
*/
map<U>(
fn: (value: T) => U | Promise<U> | RenderPipeline<U, TargetVS, TargetCF>,
): RenderPipeline<U, TargetVS, TargetCF>;
/** Handle the error case, potentially recovering to a success */
recover<U>(
fn: (error: Error) => RenderPipeline<U, TargetVS, TargetCF>,
): RenderPipeline<T | U, TargetVS, TargetCF>;
// ===== Terminal Methods =====
/**
* Convert to final PhaseOutput with explicit mapping.
*
* This is the ONLY async method in the API.
* It resolves all pending promises accumulated via map() calls,
* then applies the final mapping to produce ViewState and CarryForward.
*
* TypeScript validates the output matches TargetVS and TargetCF.
*/
toPhaseOutput(
fn: (value: T) => { viewState: TargetVS; carryForward: TargetCF },
): Promise<RenderOutcome<TargetVS, TargetCF>>;
/**
* Alternative: return PhaseOutput directly from the mapping function.
*/
toPhaseOutput(
fn: (value: T) => PhaseOutput<TargetVS, TargetCF>,
): Promise<RenderOutcome<TargetVS, TargetCF>>;
// ===== Utility Methods =====
/** Check if this is a success */
isOk(): boolean;
/** Check if this is an error */
isError(): boolean;
/** Get the value or throw */
unwrap(): T;
/** Get the value or return a default */
unwrapOr<U>(defaultValue: U): T | U;
}
Example Usage
Before (current):
async function renderSlowlyChanging(
props: PageProps & ProductPageParams,
): Promise<SlowlyRenderResult<ProductSlowViewState, ProductsCarryForward>> {
try {
const product = await getProductBySlug(props.slug);
if (!product) return notFound();
const { id, brand, description, name, priceData } = product;
return partialRender(
{ id, brand, description, name, priceData },
{ productId: id, inventoryItemId: product.inventoryItemId },
);
} catch (error) {
if (error instanceof DatabaseError) {
return serverError5xx(503);
}
throw error;
}
}
After (with typed pipeline):
async function renderSlowlyChanging(
props: PageProps & ProductPageParams,
): Promise<SlowlyRenderResult<ProductSlowViewState, ProductsCarryForward>> {
// Create pipeline factory with target output types
const Pipeline = RenderPipeline.for<ProductSlowViewState, ProductsCarryForward>();
// All map() calls are sync - chain naturally
// Only toPhaseOutput() is async
return Pipeline.try(() => getProductBySlug(props.slug)) // Start with async fetch
.recover((err) => Pipeline.serverError(503, 'Database unavailable'))
.map((product) =>
product // Conditional - returns value or RenderPipeline
? product
: Pipeline.notFound('Product not found', { slug: props.slug }),
)
.map((product) => ({
// Sync transformation
...product,
formattedPrice: formatCurrency(product.priceData.price),
}))
.toPhaseOutput((data) => ({
// ← Only async point
viewState: {
id: data.id,
brand: data.brand,
description: data.description,
name: data.name,
priceData: data.priceData,
},
carryForward: {
productId: data.id,
inventoryItemId: data.inventoryItemId,
},
}));
}
Multiple async operations - all sync until toPhaseOutput:
async function renderWithMultipleFetches(
props: PageProps,
): Promise<SlowlyRenderResult<DashboardViewState, DashboardCarryForward>> {
const Pipeline = RenderPipeline.for<DashboardViewState, DashboardCarryForward>();
// Chain async operations - map() stays sync, promises tracked internally
return Pipeline.try(() => fetchUser(props.userId)) // Async fetch stored
.map(async (user) => {
// Another async - promise chained
const stats = await fetchUserStats(user.id);
return { user, stats };
})
.recover((err) => Pipeline.serverError(503, 'Service unavailable'))
.toPhaseOutput(({ user, stats }) => ({
// ← All promises resolved here
viewState: {
userName: user.name,
totalOrders: stats.orderCount,
lastLogin: stats.lastLogin,
},
carryForward: {
userId: user.id,
},
}));
}
Simple case (no carryForward):
async function renderSimplePage(): Promise<SlowlyRenderResult<SimpleViewState, {}>> {
return RenderPipeline.for<SimpleViewState, {}>()
.try(() => fetchStaticData())
.map((d) => ({ title: d.title, body: d.body }))
.toPhaseOutput((vs) => ({ viewState: vs, carryForward: {} }));
}
Conditional error in the middle of async chain:
async function renderProduct(slug: string) {
const Pipeline = RenderPipeline.for<ProductVS, ProductCF>();
return Pipeline.try(() => getProductBySlug(slug)) // Async
.map((product) => (product ? product : Pipeline.notFound())) // Conditional error
.map(async (product) => {
// More async
const inventory = await getInventory(product.id);
return { product, inventory };
})
.map(
(
{ product, inventory }, // Sync transform
) =>
inventory.count > 0 ? { product, inventory } : Pipeline.clientError(410, 'Out of stock'),
)
.toPhaseOutput(({ product, inventory }) => ({
// Resolve all
viewState: { name: product.name, stock: inventory.count },
carryForward: { productId: product.id },
}));
}
Implementation Plan
Phase 1: Core Types Update
- Rename
PartialRendertoPhaseOutput - Add
message,code,detailsfields to error types - Update
SlowlyRenderResultandFastRenderResulttype aliases - Keep
partialRender()constructor as alias for backwards compatibility
Phase 2: RenderPipeline Monad - Core
- Implement
RenderPipelineclass with core methods - Implement static constructors (
ok,notFound,serverError, etc.) - Implement
map,flatMap,mapAsync,flatMapAsync - Implement
tryAsyncwith error catching - Implement
recoverfor error handling - Implement
toPhaseOutput()for final conversion
Phase 3: Typed Pipeline Builder
- Implement
RenderPipeline.for<VS, CF>()factory - Implement
TypedPipelineBuilderinterface - Implement
TypedRenderPipelinewith type validation - Add compile-time validation that
toPhaseOutput()matches target types
Phase 4: Integration
- Update existing examples to use new API (optional)
- Update tests
- Document the new API in README
Phase 5: Deprecation (optional)
- Mark old constructor functions as deprecated
- Provide migration guide
Migration Strategy
The new API is additive. Existing code continues to work:
partialRender()still works (alias forphaseOutput())notFound(),serverError5xx(), etc. still work- Error types are backwards compatible (new fields are optional)
New code can incrementally adopt RenderPipeline where chaining is beneficial.
Migration Example:
// Before: Direct return with manual error handling
async function render(props): Promise<SlowlyRenderResult<VS, CF>> {
const data = await fetch();
if (!data) return notFound();
return partialRender({ name: data.name, price: data.price }, { dataId: data.id });
}
// After: Using pipeline - clean chain, single async point
async function render(props): Promise<SlowlyRenderResult<VS, CF>> {
const Pipeline = RenderPipeline.for<VS, CF>();
return Pipeline.try(() => fetch()) // Async stored
.map((data) => (data ? data : Pipeline.notFound())) // Conditional
.toPhaseOutput((data) => ({
// ← Only await here
viewState: { name: data.name, price: data.price },
carryForward: { dataId: data.id },
}));
}
Open Questions
Should we add combinator functions? (e.g.,
all,race,first)- These could be useful for parallel data fetching
- Example:
Pipeline.all([fetchA(), fetchB()]).map(([a, b]) => ...)
Should error types be classes instead of interfaces?
- Would allow
instanceofchecks but adds complexity - Current recommendation: Keep as interfaces with
kinddiscriminator
- Would allow
Should
.toPhaseOutput()have a shorthand for the common case?- When
Talready matchesTargetVSandTargetCFis{} - Could allow
.toPhaseOutput()with no argument - Or require explicit
.toPhaseOutput(vs => ({ viewState: vs, carryForward: {} }))
- When
How should errors in async map functions be handled?
- If
map(async x => ...)throws, should it become a ServerError? - Or should
.recover()be able to catch it? - Current thinking: Caught exceptions become errors that
.recover()can handle
- If
Appendix: Alternative Names Considered
For the main monad (chose RenderPipeline):
RenderResult- ❌ Confusing: it's not the result, it's the pipeline producing resultsPageResult- Too page-specificRenderMonad- Too technicalRenderChain- Decent but less clear than pipelinePhaseBuilder- Could work, but "builder" implies a different patternOutcome- Too genericResponse- Confuses with HTTP response
For PhaseOutput (chose PhaseOutput):
PartialRender- ❌ Confusing: "partial" suggests incompleteRenderData- Doesn't capture carry-forwardPhaseResult- Conflicts with Result terminologyPhaseComplete- Implies finality incorrectlyRenderSuccess- Too generic, doesn't convey phase conceptRendered- Too vague
Status: Implemented ✅
Implementation complete in @jay-framework/fullstack-component:
RenderPipelineclass inlib/render-pipeline.ts- Updated types (
PhaseOutput, error types with messages) inlib/jay-stack-types.ts - Tests in
test/render-pipeline.test.ts - Documentation in
README.md
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.