Practical Example
Let's build something real: a user dashboard loader that fetches data from an API, validates it, transforms it, and handles errors gracefully.
This example combines:
- Zygos → Safe async operations with
ResultAsync - Kanon → Schema validation
- Arkhe → Data transformation utilities
The scenario
You need to load a user's dashboard data from an API. The response might be malformed, the network might fail, and you need to transform the raw data before displaying it.
Traditional approach: nested try/catch, manual validation, hope for the best.
Pithos approach: composable, type-safe, elegant.
Step 1: Define your schema
First, define what valid data looks like using Kanon:
// src/lib/schemas.ts
import {
object,
string,
number,
boolean,
array,
optional,
parse,
} from "pithos/kanon";
// Define the expected API response structure
const UserSchema = object({
id: string(),
firstName: string(),
lastName: string(),
email: string().email(),
role: string(),
createdAt: string(),
preferences: optional(
object({
theme: optional(string()),
language: optional(string()),
notifications: optional(boolean()),
})
),
});
const PostSchema = object({
id: string(),
title: string(),
content: string(),
publishedAt: optional(string()),
status: string(),
});
const DashboardSchema = object({
user: UserSchema,
posts: array(PostSchema),
stats: object({
totalViews: number(),
totalLikes: number(),
totalComments: number(),
}),
});
If you're coming from Zod, the Zod-compatible shim offers a familiar syntax with fewer imports, at the cost of slightly larger bundles:
import { z } from "pithos/kanon/helpers/as-zod.shim";
const UserSchema = z.object({
id: z.string(),
firstName: z.string(),
email: z.string().email(),
// ... same Zod API
});
Step 2: Create safe API helpers
Wrap fetch operations with Zygos for safe error handling:
// src/lib/api.ts
import {
ResultAsync,
errAsync,
okAsync,
} from "pithos/zygos/result/result-async";
// Create a safe fetch wrapper
const safeFetch = ResultAsync.fromThrowable(
fetch,
(error) => `Network error: ${error}`
);
// Create a safe JSON parser
const safeJson = <T>(response: Response) =>
ResultAsync.fromThrowable(
async () => (await response.json()) as T,
(error) => `JSON parse error: ${error}`
)();
Step 3: Add data transformation
Use Arkhe utilities to transform the validated data:
// src/lib/transformers.ts
import { groupBy } from "pithos/arkhe/array/group-by";
import { capitalize } from "pithos/arkhe/string/capitalize";
type User = {
id: string;
firstName: string;
lastName: string;
email: string;
role: string;
createdAt: string;
preferences?: {
theme?: string;
language?: string;
notifications?: boolean;
};
};
type Post = {
id: string;
title: string;
content: string;
publishedAt?: string;
status: string;
};
// Transform user data for display
function formatUser(user: User) {
return {
id: user.id,
fullName: `${capitalize(user.firstName)} ${capitalize(user.lastName)}`,
email: user.email,
role: capitalize(user.role),
preferences: user.preferences ?? {
theme: "light",
language: "en",
notifications: true,
},
};
}
// Transform posts for the dashboard
function formatPosts(posts: Post[]) {
const grouped = groupBy(posts, (post) => post.status);
return {
published: grouped["published"] ?? [],
draft: grouped["draft"] ?? [],
total: posts.length,
};
}
Step 4: Compose everything together
Now combine all pieces into a single, composable pipeline:
// src/lib/api.ts (continued)
type DashboardData = {
user: ReturnType<typeof formatUser>;
posts: ReturnType<typeof formatPosts>;
stats: {
totalViews: number;
totalLikes: number;
totalComments: number;
};
};
function loadDashboard(userId: string): ResultAsync<DashboardData, string> {
return safeFetch(`/api/dashboard/${userId}`)
.andThen((response) => {
if (!response.ok) {
return errAsync(`HTTP error: ${response.status}`);
}
return okAsync(response);
})
.andThen((response) => safeJson<unknown>(response))
.andThen((data) => {
const result = parse(DashboardSchema, data);
if (!result.success) {
return errAsync(`Invalid data: ${result.error}`);
}
return okAsync(result.data);
})
.map((data) => ({
user: formatUser(data.user),
posts: formatPosts(data.posts),
stats: data.stats,
}));
}
Step 5: Use it in your app
With the pipeline in place, consuming the result in a component is straightforward: pattern match on success or error and render accordingly:
// src/components/Dashboard.tsx
async function initDashboard() {
const result = await loadDashboard("user-123");
if (result.isErr()) {
// Handle error - show message, retry, fallback...
showError(result.error);
return;
}
// TypeScript knows result.value is DashboardData
const { user, posts, stats } = result.value;
renderHeader(user.fullName, user.role);
renderPostsList(posts.published);
renderDraftsBadge(posts.draft.length);
renderStats(stats);
}
Live Demo
- schemas.ts
- api.ts
- transformers.ts
- useDashboard.ts
- Dashboard.tsx
/**
* Kanon schemas for validating API responses
* Using Pithos/Kanon v3 for schema validation
*/
import {
object,
string,
number,
boolean,
array,
optional,
} from "pithos/kanon/index";
// Define the expected API response structure
export const UserSchema = object({
id: string(),
firstName: string(),
lastName: string(),
email: string().email(),
role: string(),
createdAt: string(),
preferences: optional(
object({
theme: optional(string()),
language: optional(string()),
notifications: optional(boolean()),
})
),
});
export const PostSchema = object({
id: string(),
title: string(),
content: string(),
publishedAt: optional(string()),
status: string(),
});
export const DashboardSchema = object({
user: UserSchema,
posts: array(PostSchema),
stats: object({
totalViews: number(),
totalLikes: number(),
totalComments: number(),
}),
});
/**
* Safe API helpers using Zygos ResultAsync
*
* This file demonstrates the Zygos pattern for type-safe async operations.
* In this example, we use mock data, but the pattern works identically with real APIs.
*/
import { ResultAsync, errAsync, okAsync } from "pithos/zygos/result/result-async";
import { parse } from "pithos/kanon/index";
import { DashboardSchema } from "./schemas";
import { formatUser, formatPosts } from "./transformers";
import type { DashboardData, RawDashboardData } from "./types";
/**
* Load dashboard data with full error handling pipeline
*
* This demonstrates the Zygos ResultAsync pattern:
* 1. Wrap async operations in ResultAsync
* 2. Chain with andThen for sequential operations
* 3. Use map for transformations
* 4. All errors are captured and typed
*/
export function loadDashboard(userId: string): ResultAsync<DashboardData, string> {
return ResultAsync.fromPromise(
fetch(`/api/dashboard/${userId}`),
(error) => `Network error: ${error}`
)
.andThen((response) => {
if (!response.ok) {
return errAsync(`HTTP error: ${response.status}`);
}
return ResultAsync.fromPromise(
response.json() as Promise<unknown>,
(error) => `JSON parse error: ${error}`
);
})
.andThen((data) => {
const result = parse(DashboardSchema, data);
if (!result.success) {
return errAsync(`Invalid data: ${result.error}`);
}
// Safe cast: parse() validates that data matches RawDashboardData structure
return okAsync(result.data as RawDashboardData);
})
.map((data) => ({
user: formatUser(data.user),
posts: formatPosts(data.posts),
stats: data.stats,
}));
}
/**
* Data transformers using Arkhe utilities
*/
import { groupBy } from "pithos/arkhe/array/group-by";
import { capitalize } from "pithos/arkhe/string/capitalize";
import type { User, Post, FormattedUser, FormattedPosts } from "./types";
/**
* Transform user data for display
*/
export function formatUser(user: User): FormattedUser {
const defaultPrefs = {
theme: "light",
language: "en",
notifications: true,
};
return {
id: user.id,
fullName: `${capitalize(user.firstName)} ${capitalize(user.lastName)}`,
email: user.email,
role: capitalize(user.role),
preferences: {
theme: user.preferences?.theme ?? defaultPrefs.theme,
language: user.preferences?.language ?? defaultPrefs.language,
notifications: user.preferences?.notifications ?? defaultPrefs.notifications,
},
};
}
/**
* Transform posts for the dashboard, grouped by status
*/
export function formatPosts(posts: Post[]): FormattedPosts {
const grouped = groupBy(posts, (post) => post.status);
return {
published: grouped["published"] ?? [],
draft: grouped["draft"] ?? [],
total: posts.length,
};
}
/**
* Custom hook for loading dashboard data
* Demonstrates the Pithos pipeline in a React context
*/
import { useState, useEffect, useCallback } from "react";
import { parse } from "pithos/kanon/index";
import { DashboardSchema } from "@/lib/schemas";
import { formatUser, formatPosts } from "@/lib/transformers";
import { mockDashboardData } from "@/lib/mock-data";
import type { DashboardData, RawDashboardData } from "@/lib/types";
type DashboardState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: DashboardData }
| { status: "error"; error: string };
/**
* Simulates the full Pithos pipeline with mock data
* In production, this would use the loadDashboard function from api.ts
*/
export function useDashboard(userId: string) {
const [state, setState] = useState<DashboardState>({ status: "idle" });
const load = useCallback(async () => {
setState({ status: "loading" });
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
// Step 1: Validate with Kanon
const result = parse(DashboardSchema, mockDashboardData);
if (!result.success) {
setState({ status: "error", error: `Validation failed: ${result.error}` });
return;
}
const rawData = result.data as RawDashboardData;
// Step 2: Transform with Arkhe utilities
const dashboardData: DashboardData = {
user: formatUser(rawData.user),
posts: formatPosts(rawData.posts),
stats: rawData.stats,
};
setState({ status: "success", data: dashboardData });
} catch (err) {
setState({ status: "error", error: `Unexpected error: ${err}` });
}
}, [userId]);
useEffect(() => {
load();
}, [load]);
return { state, reload: load };
}
import { useDashboard } from "@/hooks/useDashboard";
import { Header } from "./Header";
import { StatsCards } from "./StatsCards";
import { PostsList } from "./PostsList";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { ErrorDisplay } from "./ErrorDisplay";
import { DemoControls } from "./DemoControls";
export function Dashboard() {
const { state, reload } = useDashboard("user-123");
if (state.status === "idle" || state.status === "loading") {
return <LoadingSkeleton />;
}
if (state.status === "error") {
return <ErrorDisplay error={state.error} onRetry={reload} />;
}
const { user, posts, stats } = state.data;
return (
<div className="min-h-screen bg-background">
<Header user={user} />
<main className="container mx-auto space-y-6 px-4 py-6">
<StatsCards stats={stats} />
<div className="grid gap-6 lg:grid-cols-2">
<PostsList
posts={posts.published}
title="Published Posts"
description="Your live content"
variant="published"
/>
<PostsList
posts={posts.draft}
title="Drafts"
description="Work in progress"
variant="draft"
/>
</div>
{/* Demo controls for testing error handling */}
<DemoControls />
{/* Pithos info card */}
<div className="rounded-lg border bg-muted/50 p-4">
<h3 className="font-semibold">✨ Powered by Pithos</h3>
<p className="mt-1 text-sm text-muted-foreground">
This dashboard uses <strong>Kanon</strong> for schema validation,{" "}
<strong>Arkhe</strong> for data transformation (capitalize, groupBy), and{" "}
<strong>Zygos</strong> patterns for type-safe error handling.
</p>
</div>
</main>
</div>
);
}
The demo above is more complete than the code snippets: it's embedded in a React project and includes the user interface.
The complete source code is available on GitHub.
What you've achieved
With minimal code, you have:
Type-safe API calls - No more any from response.json()
Validated data - Kanon ensures the API response matches your schema
Graceful error handling - Every failure point is captured and typed
Clean transformations - Arkhe utilities make data shaping readable
Composable pipeline - Easy to add caching, retries, or logging
Compare to traditional code
Without Pithos, this would typically involve:
// ❌ The traditional way
async function loadDashboard(userId: string) {
try {
const response = await fetch(`/api/dashboard/${userId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); // any 😱
// Manual validation...
if (!data.user || !data.posts) {
throw new Error("Invalid data");
}
// Manual transformation...
return {
user: {
fullName: data.user.firstName + " " + data.user.lastName,
// ... more manual work
},
// ... more manual work
};
} catch (error) {
console.error(error); // Now what?
return null; // Caller has to check for null everywhere
}
}
Next steps
Now that you've seen how modules work together:
- Explore Arkhe utilities for more data manipulation
- Learn about Kanon schemas for complex validation
- Master Zygos patterns for advanced error handling
- Browse Use Cases for specific problems you're solving