Procedures
Procedures are the fundamental building blocks of c4c. They are type-safe functions with contracts that define inputs, outputs, and business logic.
What is a Procedure?
A procedure consists of two parts:
- Contract - Defines the interface (input/output schemas)
- Handler - Implements the business logic
typescript
export const createUser: Procedure = {
contract: {
input: z.object({ name: z.string(), email: z.string() }),
output: z.object({ id: z.string(), name: z.string(), email: z.string() })
},
handler: async (input) => {
// Business logic here
return { id: generateId(), ...input };
}
};Basic Procedure
Here's a simple procedure:
typescript
import { z } from "zod";
import type { Procedure } from "@c4c/core";
export const greet: Procedure = {
contract: {
input: z.object({
name: z.string(),
}),
output: z.object({
message: z.string(),
}),
},
handler: async (input) => {
return {
message: `Hello, ${input.name}!`
};
},
};Contract Definition
Contracts use Zod for schema validation:
typescript
const contract = {
input: z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).optional(),
role: z.enum(["admin", "user"]),
metadata: z.record(z.string()),
}),
output: z.object({
id: z.string().uuid(),
success: z.boolean(),
})
};Common Schema Types
typescript
// Strings
z.string()
z.string().min(5)
z.string().max(100)
z.string().email()
z.string().url()
z.string().uuid()
// Numbers
z.number()
z.number().int()
z.number().positive()
z.number().min(0).max(100)
// Booleans
z.boolean()
// Dates
z.date()
z.string().datetime() // ISO 8601 string
// Arrays
z.array(z.string())
z.string().array()
// Objects
z.object({ key: z.string() })
// Unions
z.union([z.string(), z.number()])
// Enums
z.enum(["red", "green", "blue"])
// Optional
z.string().optional()
// Nullable
z.string().nullable()
// Default values
z.string().default("default value")Auto-Naming
Procedures can use auto-naming, where the export name becomes the procedure name:
typescript
// Auto-named as "createUser"
export const createUser: Procedure = {
contract: {
// No name specified - uses export name
input: ...,
output: ...
},
handler: ...
};Benefits:
- Less boilerplate
- IDE refactoring support (F2 rename works!)
- Single source of truth
Explicit Naming
For public APIs or when you need specific names:
typescript
export const create: Procedure = {
contract: {
name: "users.create", // Explicit name
input: ...,
output: ...
},
handler: ...
};Handler Implementation
The handler is an async function that receives input and context:
typescript
handler: async (input, context) => {
// Access validated input
console.log(input.name);
// Access execution context
console.log(context.requestId);
console.log(context.metadata);
// Perform business logic
const result = await database.save(input);
// Return validated output
return result;
}Execution Context
The context object provides:
typescript
interface ExecutionContext {
requestId: string; // Unique request ID
metadata: Record<string, any>; // Custom metadata
trace?: { // OpenTelemetry trace info
traceId: string;
spanId: string;
};
}Error Handling
Procedures can throw errors which are automatically handled:
typescript
handler: async (input) => {
if (!input.email.includes("@")) {
throw new Error("Invalid email format");
}
try {
return await api.createUser(input);
} catch (error) {
throw new Error(`Failed to create user: ${error.message}`);
}
}Contract Metadata
Add metadata to your contracts for documentation and tooling:
typescript
const contract = {
name: "users.create",
description: "Create a new user account",
input: z.object({ ... }),
output: z.object({ ... }),
metadata: {
exposure: "external", // "internal" | "external"
roles: ["api-endpoint", "sdk-client"],
tags: ["users", "write"],
version: "1.0.0",
deprecated: false,
}
};Complete Example
Here's a complete procedure with all features:
typescript
import { z } from "zod";
import type { Procedure } from "@c4c/core";
// Shared schemas
const userInputSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(18).optional(),
});
const userOutputSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
age: z.number().optional(),
createdAt: z.string().datetime(),
});
// Create user procedure
export const createUser: Procedure = {
contract: {
name: "users.create",
description: "Create a new user account",
input: userInputSchema,
output: userOutputSchema,
metadata: {
exposure: "external",
roles: ["api-endpoint", "sdk-client"],
tags: ["users", "write"],
},
},
handler: async (input, context) => {
console.log(`[${context.requestId}] Creating user: ${input.email}`);
// Validate business rules
const existing = await database.users.findByEmail(input.email);
if (existing) {
throw new Error("User already exists");
}
// Create user
const user = {
id: crypto.randomUUID(),
...input,
createdAt: new Date().toISOString(),
};
await database.users.create(user);
console.log(`[${context.requestId}] User created: ${user.id}`);
return user;
},
};
// Get user procedure
export const getUser: Procedure = {
contract: {
name: "users.get",
description: "Get user by ID",
input: z.object({ id: z.string().uuid() }),
output: userOutputSchema,
metadata: {
exposure: "external",
roles: ["api-endpoint", "sdk-client"],
tags: ["users", "read"],
},
},
handler: async (input, context) => {
const user = await database.users.findById(input.id);
if (!user) {
throw new Error("User not found");
}
return user;
},
};
// Update user procedure
export const updateUser: Procedure = {
contract: {
name: "users.update",
description: "Update user information",
input: z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
age: z.number().int().min(18).optional(),
}),
output: userOutputSchema,
metadata: {
exposure: "external",
roles: ["api-endpoint", "sdk-client"],
tags: ["users", "write"],
},
},
handler: async (input, context) => {
const user = await database.users.findById(input.id);
if (!user) {
throw new Error("User not found");
}
const updated = {
...user,
...input,
};
await database.users.update(updated);
return updated;
},
};
// Delete user procedure
export const deleteUser: Procedure = {
contract: {
name: "users.delete",
description: "Delete user account",
input: z.object({ id: z.string().uuid() }),
output: z.object({ success: z.boolean() }),
metadata: {
exposure: "external",
roles: ["api-endpoint", "sdk-client"],
tags: ["users", "delete"],
},
},
handler: async (input, context) => {
await database.users.delete(input.id);
return { success: true };
},
};Organizing Procedures
Single File
typescript
// procedures.ts
export const createUser: Procedure = { ... };
export const getUser: Procedure = { ... };
export const updateUser: Procedure = { ... };Multiple Files
typescript
// procedures/users.ts
export const createUser: Procedure = { ... };
export const getUser: Procedure = { ... };
// procedures/products.ts
export const createProduct: Procedure = { ... };
export const getProduct: Procedure = { ... };Module Structure
typescript
// modules/users/procedures.ts
export const create: Procedure = {
contract: { name: "users.create", ... },
...
};
// modules/products/procedures.ts
export const create: Procedure = {
contract: { name: "products.create", ... },
...
};Best Practices
- Use Zod for validation - Let Zod handle input/output validation
- Keep handlers focused - One procedure = one responsibility
- Use descriptive names - Make names self-documenting
- Add descriptions - Document what procedures do
- Handle errors gracefully - Throw descriptive errors
- Reuse schemas - Define common schemas once
- Add metadata - Use metadata for documentation and tooling
- Test thoroughly - Write tests for your procedures