跳到主要内容

Type Safety Guidelines

TypeScript patterns and type definitions for Viben frontend.

Overview​

This document covers TypeScript type definition patterns, Zod schema usage, API type generation, generic component patterns, and type narrowing best practices.


Type Definition Patterns​

Interface vs Type​

// Use interface for object shapes that may be extended
interface Task {
id: string;
title: string;
status: TaskStatus;
}

interface TaskWithDetails extends Task {
description: string;
assignee: User;
}

// Use type for unions, intersections, and computed types
type TaskStatus = "backlog" | "in_progress" | "completed";
type TaskAction = "create" | "update" | "delete";
type PartialTask = Partial<Task>;
type TaskKeys = keyof Task;

Discriminated Unions​

// Use discriminated unions for state machines
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };

function renderState<T>(state: AsyncState<T>) {
switch (state.status) {
case "idle":
return <Idle />;
case "loading":
return <Spinner />;
case "success":
return <Data data={state.data} />; // TypeScript knows data exists
case "error":
return <Error error={state.error} />;
}
}

Branded Types​

// Use branded types for type-safe IDs
type TaskId = string & { readonly __brand: "TaskId" };
type UserId = string & { readonly __brand: "UserId" };

function createTaskId(id: string): TaskId {
return id as TaskId;
}

function getTask(id: TaskId): Task {
// Can only be called with TaskId, not any string
}

// Error: Argument of type 'string' is not assignable to parameter of type 'TaskId'
getTask("some-string");

// OK
getTask(createTaskId("task-123"));

Schema Validation (Zod)​

Basic Schemas​

import { z } from "zod";

// Define schema
const TaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
status: z.enum(["backlog", "in_progress", "completed"]),
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
createdAt: z.coerce.date(),
});

// Infer type from schema
type Task = z.infer<typeof TaskSchema>;

// Validate data
const result = TaskSchema.safeParse(data);
if (result.success) {
// result.data is typed as Task
console.log(result.data.title);
} else {
// result.error contains validation errors
console.error(result.error.issues);
}

Form Validation​

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const CreateTaskSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
priority: z.enum(["P0", "P1", "P2", "P3"]),
});

type CreateTaskForm = z.infer<typeof CreateTaskSchema>;

function CreateTaskForm() {
const form = useForm<CreateTaskForm>({
resolver: zodResolver(CreateTaskSchema),
defaultValues: {
title: "",
priority: "P2",
},
});

const onSubmit = (data: CreateTaskForm) => {
// data is fully typed and validated
};

return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register("title")} />
{form.formState.errors.title && (
<span>{form.formState.errors.title.message}</span>
)}
{/* ... */}
</form>
);
}

API Response Validation​

const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema,
error: z.string().optional(),
});

const TaskListResponseSchema = ApiResponseSchema(z.array(TaskSchema));

async function fetchTasks(): Promise<Task[]> {
const response = await fetch("/api/tasks");
const json = await response.json();

const result = TaskListResponseSchema.safeParse(json);
if (!result.success) {
throw new Error(`Invalid API response: ${result.error.message}`);
}

return result.data.data;
}

API Type Generation​

Gateway Client Types​

// packages/core/src/gateway/client/types.ts
export interface GatewayClient {
agent: {
list(params: AgentListParams): Promise<AgentListResponse>;
get(id: string): Promise<AgentGetResponse>;
run(params: AgentRunParams): AsyncIterable<AgentEvent>;
};
task: {
list(params: TaskListParams): Promise<TaskListResponse>;
create(params: TaskCreateParams): Promise<TaskCreateResponse>;
// ...
};
}

// Request/Response types match API specs
interface AgentListParams {
workspace_path: string;
include_global?: boolean;
}

interface AgentListResponse {
agents: Agent[];
total: number;
}

Hook Type Integration​

// hooks/useAgents.ts
import type { Agent, AgentListParams } from "@viben/core/gateway";

interface UseAgentsOptions extends Omit<AgentListParams, "workspace_path"> {
enabled?: boolean;
}

interface UseAgentsReturn {
agents: Agent[];
isLoading: boolean;
isError: boolean;
refresh: () => void;
}

export function useAgents(
workspacePath: string,
options: UseAgentsOptions = {}
): UseAgentsReturn {
// Implementation
}

Generic Component Patterns​

Generic Props​

// Generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyState?: React.ReactNode;
}

function List<T>({
items,
renderItem,
keyExtractor,
emptyState,
}: ListProps<T>) {
if (items.length === 0) {
return emptyState ?? null;
}

return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}

// Usage - T is inferred as Task
<List
items={tasks}
renderItem={(task) => <TaskCard task={task} />}
keyExtractor={(task) => task.id}
/>

Generic Select Component​

interface SelectOption<T> {
value: T;
label: string;
}

interface SelectProps<T> {
options: SelectOption<T>[];
value: T | null;
onChange: (value: T) => void;
placeholder?: string;
}

function Select<T extends string | number>({
options,
value,
onChange,
placeholder,
}: SelectProps<T>) {
return (
<select
value={value ?? ""}
onChange={(e) => {
const option = options.find((o) => String(o.value) === e.target.value);
if (option) onChange(option.value);
}}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
);
}

Polymorphic Components​

type PolymorphicRef<C extends React.ElementType> =
React.ComponentPropsWithRef<C>["ref"];

type PolymorphicProps<C extends React.ElementType, Props = {}> = Props &
Omit<React.ComponentPropsWithoutRef<C>, keyof Props> & {
as?: C;
ref?: PolymorphicRef<C>;
};

interface ButtonOwnProps {
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
}

type ButtonProps<C extends React.ElementType = "button"> = PolymorphicProps<
C,
ButtonOwnProps
>;

function Button<C extends React.ElementType = "button">({
as,
variant = "primary",
size = "md",
...props
}: ButtonProps<C>) {
const Component = as || "button";
return <Component className={`btn btn-${variant} btn-${size}`} {...props} />;
}

// Usage
<Button>Click me</Button>
<Button as="a" href="/link">Link Button</Button>
<Button as={Link} to="/page">Router Link</Button>

Type Narrowing Best Practices​

Type Guards​

// Custom type guard
function isTask(value: unknown): value is Task {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"title" in value &&
"status" in value
);
}

// Usage
function processItem(item: unknown) {
if (isTask(item)) {
// item is narrowed to Task
console.log(item.title);
}
}

// Discriminated union type guard
function isSuccessResponse<T>(
response: AsyncState<T>
): response is { status: "success"; data: T } {
return response.status === "success";
}

Assertion Functions​

function assertNonNull<T>(
value: T,
message = "Value is null or undefined"
): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(message);
}
}

function processTask(task: Task | null) {
assertNonNull(task, "Task must exist");
// task is narrowed to Task
console.log(task.title);
}

in Operator Narrowing​

interface Dog {
bark(): void;
}

interface Cat {
meow(): void;
}

function makeSound(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark(); // animal is Dog
} else {
animal.meow(); // animal is Cat
}
}

Array Type Guards​

function isNonEmptyArray<T>(arr: T[]): arr is [T, ...T[]] {
return arr.length > 0;
}

function processItems(items: Task[]) {
if (isNonEmptyArray(items)) {
// items[0] is guaranteed to exist
console.log(items[0].title);
}
}

Exhaustiveness Checking​

type TaskStatus = "backlog" | "in_progress" | "completed";

function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}

function getStatusLabel(status: TaskStatus): string {
switch (status) {
case "backlog":
return "Backlog";
case "in_progress":
return "In Progress";
case "completed":
return "Completed";
default:
// If we add a new status and forget to handle it,
// TypeScript will error here
return assertNever(status);
}
}

Common Utility Types​

// Pick specific fields
type TaskSummary = Pick<Task, "id" | "title" | "status">;

// Omit specific fields
type TaskWithoutId = Omit<Task, "id">;

// Make all fields optional
type PartialTask = Partial<Task>;

// Make all fields required
type RequiredTask = Required<Task>;

// Make all fields readonly
type ReadonlyTask = Readonly<Task>;

// Extract types from arrays/promises
type TaskItem = Task[];
type SingleTask = TaskItem[number]; // Task

// Template literal types
type EventName = `on${Capitalize<TaskAction>}`; // "onCreate" | "onUpdate" | "onDelete"

// Conditional types
type IdType<T> = T extends { id: infer U } ? U : never;
type TaskIdType = IdType<Task>; // string