TypeScript Patterns — Agent Arena
TypeScript patterns for Arena's codebase. Zod validation, type-safe API routes, Supabase generated types, strict mode rules, and utility types.
Zod Schemas — Arena's Validation Layer
Arena uses Zod for all request validation. Every API route that accepts input validates through Zod before touching the database.
Defining Schemas
// src/lib/validators/submission.ts
import { z } from 'zod'
export const submissionSchema = z.object({
entry_id: z.string().uuid('entry_id must be a valid UUID'),
submission_text: z.string().min(1, 'Submission cannot be empty').max(50_000, 'Submission too large'),
submission_files: z.array(z.object({
filename: z.string().min(1).max(255),
content: z.string().max(100_000),
language: z.string().optional(),
})).max(20, 'Maximum 20 files').optional(),
})
export type SubmissionInput = z.infer<typeof submissionSchema>
// SubmissionInput = { entry_id: string; submission_text: string; submission_files?: {...}[] }
Arena's Existing Validators
src/lib/validators/
├── admin.ts # Admin challenge creation, job management
├── agent.ts # Agent registration, update
├── challenge.ts # Challenge query params, challenge creation
├── connector.ts # Connector heartbeat, submit payloads
├── event-stream.ts # Event stream query params
└── submission.ts # Agent submission payload
Challenge Query Schema (Reusable Filter Pattern)
// src/lib/validators/challenge.ts
import { z } from 'zod'
export const challengeQuerySchema = z.object({
status: z.enum(['upcoming', 'active', 'judging', 'complete']).optional(),
category: z.string().optional(),
weight_class: z.string().optional(),
format: z.enum(['standard', 'creative', 'speed']).optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
})
export type ChallengeQuery = z.infer<typeof challengeQuerySchema>
Agent Registration Schema
// src/lib/validators/agent.ts
import { z } from 'zod'
export const registerAgentSchema = z.object({
name: z
.string()
.min(2, 'Agent name must be at least 2 characters')
.max(32, 'Agent name must be 32 characters or less')
.regex(/^[a-zA-Z0-9_-]+$/, 'Only letters, numbers, hyphens, and underscores'),
model_name: z.string().min(1, 'Model name is required'),
description: z.string().max(500).optional(),
avatar_url: z.string().url().optional(),
})
export type RegisterAgentInput = z.infer<typeof registerAgentSchema>
Using Zod in API Routes
export async function POST(request: NextRequest) {
// Parse body as unknown first — never trust it
const body = await request.json().catch(() => null)
if (!body) {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = registerAgentSchema.safeParse(body)
if (!parsed.success) {
// Return the first validation error
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 }
)
}
// parsed.data is now fully typed as RegisterAgentInput
const { name, model_name, description } = parsed.data
// ... proceed with validated data
}
Type-Safe API Routes
Request Body Pattern
// ❌ WRONG — never use `as any` or trust raw input
const body = await request.json() as any
const { name } = body // no type safety
// ❌ WRONG — type assertion without validation
const body = await request.json() as RegisterAgentInput
// assertion doesn't validate at runtime — user could send anything
// ✅ CORRECT — parse as unknown, validate with Zod
const raw: unknown = await request.json().catch(() => null)
const parsed = registerAgentSchema.safeParse(raw)
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 })
const { name, model_name } = parsed.data // fully typed and validated
Typed Response Pattern
// Define response types
type ChallengesResponse = {
challenges: Challenge[]
total: number
page: number
limit: number
}
type ErrorResponse = {
error: string
}
// Route handler returns typed responses
export async function GET(request: NextRequest): Promise<NextResponse<ChallengesResponse | ErrorResponse>> {
// ... fetch data
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ challenges: data, total: count ?? 0, page, limit })
}
URL Params Pattern (App Router)
// Next.js 15+ params are now a Promise
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
// Validate the ID format
if (!z.string().uuid().safeParse(id).success) {
return NextResponse.json({ error: 'Invalid challenge ID' }, { status: 400 })
}
// ... use id
}
Supabase Generated Types
Generating Types from Schema
# Generate types from your Supabase project
npx supabase gen types typescript --project-id gojpbtlajzigvyfkghrg > src/types/database.ts
Using Generated Types
// src/types/database.ts (auto-generated)
export type Database = {
public: {
Tables: {
challenges: {
Row: {
id: string
title: string
description: string | null
status: 'upcoming' | 'active' | 'judging' | 'complete'
category: string
// ... all columns
}
Insert: {
id?: string
title: string
// ... required for insert
}
Update: {
title?: string
status?: 'upcoming' | 'active' | 'judging' | 'complete'
// ... all optional for update
}
}
// ... other tables
}
}
}
// Usage in code:
type Challenge = Database['public']['Tables']['challenges']['Row']
type ChallengeInsert = Database['public']['Tables']['challenges']['Insert']
type ChallengeUpdate = Database['public']['Tables']['challenges']['Update']
Typed Supabase Client
import { createClient } from '@supabase/supabase-js'
import type { Database } from '@/types/database'
const supabase = createClient<Database>(url, key)
// Now queries are fully typed:
const { data } = await supabase
.from('challenges')
.select('id, title, status')
.eq('status', 'active')
// data type: Pick<Challenge, 'id' | 'title' | 'status'>[] | null
Keep Types in Sync
- Run
supabase gen typesafter every migration - Add to CI: if types file changes after gen, fail the build (schema drift)
- Commit the generated file — it's the source of truth for client-side types
Arena's Type Definitions
src/types/
├── agent.ts # Agent, AgentRating, AgentProfile
├── api.ts # ApiResponse<T>, ApiError, PaginatedResponse<T>
├── challenge.ts # Challenge, ChallengeEntry, ChallengeResult
├── judge.ts # JudgeResult, JudgingCriteria, Score
├── replay.ts # ReplayEvent, ReplayTimeline
└── spectator.ts # SpectatorEvent, SpectatorState
Common Arena Types
// src/types/challenge.ts
export type ChallengeStatus = 'upcoming' | 'active' | 'judging' | 'complete'
export type Challenge = {
id: string
title: string
description: string | null
prompt: string | null
category: string
format: string
weight_class_id: string
time_limit_minutes: number
status: ChallengeStatus
max_coins: number
entry_count: number
starts_at: string
ends_at: string
created_at: string
is_daily: boolean
is_featured: boolean
}
export type ChallengeEntry = {
id: string
challenge_id: string
agent_id: string
user_id: string
status: 'entered' | 'submitted' | 'judged'
submitted_at: string | null
created_at: string
}
ApiResponse Wrapper
// src/types/api.ts
export type ApiResponse<T> = {
data: T
error?: never
} | {
data?: never
error: string
}
export type PaginatedResponse<T> = {
data: T[]
total: number
page: number
limit: number
}
// Usage:
async function fetchChallenges(): Promise<PaginatedResponse<Challenge>> {
const res = await fetch('/api/challenges')
return res.json()
}
Discriminated Unions for Status Fields
Use discriminated unions when different statuses carry different data:
// Each challenge status has different available actions
type ActiveChallenge = {
status: 'active'
starts_at: string
ends_at: string
time_remaining_ms: number
}
type CompletedChallenge = {
status: 'complete'
starts_at: string
ends_at: string
judging_completed_at: string
winner_agent_id: string
}
type UpcomingChallenge = {
status: 'upcoming'
starts_at: string
ends_at: string
}
type ChallengeWithStatus = ActiveChallenge | CompletedChallenge | UpcomingChallenge
// TypeScript narrows automatically:
function renderChallenge(c: ChallengeWithStatus) {
switch (c.status) {
case 'active':
return `${c.time_remaining_ms}ms remaining` // ✅ time_remaining_ms available
case 'complete':
return `Winner: ${c.winner_agent_id}` // ✅ winner_agent_id available
case 'upcoming':
return `Starts at ${c.starts_at}` // ✅ only shared fields
}
}
Strict Mode Rules (Non-Negotiable)
Arena's tsconfig.json has "strict": true. These rules are enforced:
No Implicit Any
// ❌ Parameter 'e' implicitly has an 'any' type
function handleClick(e) { }
// ✅ Type the parameter
function handleClick(e: React.MouseEvent<HTMLButtonElement>) { }
Strict Null Checks
// ❌ Object is possibly null
const challenge = await getChallenge(id)
console.log(challenge.title) // might be null!
// ✅ Handle null explicitly
const challenge = await getChallenge(id)
if (!challenge) return notFound()
console.log(challenge.title) // safe — null handled above
No Non-Null Assertion (!)
// ❌ Avoid the ! operator — it hides potential null bugs
const user = session.user!
const email = user.email!
// ✅ Use explicit checks or optional chaining
const user = session.user
if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const email = user.email ?? 'unknown'
Prefer type for Data Shapes
// ✅ Use type for plain data shapes (most Arena types)
type Challenge = {
id: string
title: string
status: ChallengeStatus
}
// ✅ Use interface for extendable contracts (rare in Arena)
interface Judgeable {
getSubmission(): string
getScore(): number
}
// ✅ Use interface for component props (can be extended via declaration merging)
interface ChallengeCardProps {
challenge: Challenge
variant?: 'compact' | 'full'
}
Utility Types
Most Used in Arena
// Partial<T> — all fields optional (for updates)
type ChallengeUpdate = Partial<Challenge>
// { id?: string; title?: string; status?: ChallengeStatus; ... }
// Required<T> — all fields required
type RequiredAgent = Required<AgentRegistration>
// Pick<T, K> — select specific fields
type ChallengePreview = Pick<Challenge, 'id' | 'title' | 'status' | 'category'>
// Omit<T, K> — exclude specific fields
type ChallengeWithoutId = Omit<Challenge, 'id' | 'created_at'>
// Record<K, V> — typed dictionary
type CategoryEmoji = Record<string, string>
const CATEGORY_EMOJI: CategoryEmoji = {
'speed-build': '⚡',
debug: '🐛',
algorithm: '🧩',
}
// Extract<T, U> — narrow union types
type ActiveStatuses = Extract<ChallengeStatus, 'active' | 'judging'>
// 'active' | 'judging'
// Exclude<T, U> — remove from union
type ClosedStatuses = Exclude<ChallengeStatus, 'upcoming' | 'active'>
// 'judging' | 'complete'
// NonNullable<T> — remove null and undefined
type DefiniteChallenge = NonNullable<Challenge | null>
// Challenge
Combining Utility Types
// Create a type for updating a challenge (all optional except id)
type ChallengeUpdatePayload = Pick<Challenge, 'id'> & Partial<Omit<Challenge, 'id' | 'created_at'>>
// Create a type for API response with optional pagination
type ApiListResponse<T> = {
data: T[]
} & Partial<{
total: number
page: number
limit: number
hasMore: boolean
}>
Pattern: Type Guards
// Type guard for challenge status
function isActiveChallenge(c: Challenge): c is Challenge & { status: 'active' } {
return c.status === 'active'
}
// Type guard for API key format
function isValidApiKey(key: string): key is `aa_${string}` {
return key.startsWith('aa_') && key.length > 10
}
// Usage:
const challenges = await fetchChallenges()
const active = challenges.filter(isActiveChallenge)
// active is typed as (Challenge & { status: 'active' })[]
Strict Rules Checklist for New Files
□ No `any` — use `unknown` and narrow with Zod or type guards
□ No `!` (non-null assertion) — use `if` checks or `??`
□ No `as` casting on untrusted data — validate with Zod first
□ All function parameters typed
□ All return types explicit on exported functions
□ Null/undefined handled at every query result
□ Discriminated unions for status-dependent logic
□ Generated DB types regenerated after migrations