Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
vs Plain TypeScript - Zagora
Skip to content

vs Plain TypeScript

Why use Zagora over plain functions

Philosophy: Plain TypeScript offers types but no runtime guarantees. Zagora bridges the gap with runtime validation while maintaining full type inference. You get the ergonomics of pure functions with the safety of schema validation. Here's what Zagora adds.

Feature Comparison

AspectZagoraPlain TypeScript
Runtime validationYes (StandardSchema)Manual implementation
Compile-time typesYes (full inference)Manual type annotations
Error handlingStructured { ok, error }try/catch or manual
Default values at runtimeAutomatic from schemaManual implementation
Tuple argument spreadingBuilt-inNot applicable
Caching/memoizationBuilt-in adapterManual implementation
Context injectionBuilt-inManual prop drilling

Key Differences

1. Runtime Validation

Plain TypeScript: Types exist only at compile-time

function createUser(data: { name: string; age: number }) {
  // Types say this is safe, but at runtime...
  return { id: '1', ...data };
}
 
// TypeScript is happy, but runtime disaster awaits
createUser(JSON.parse(untrustedInput));  // Could be anything!

Zagora: Validates at runtime

const createUser = zagora()
  .input(z.object({
    name: z.string().min(1),
    age: z.number().min(0)
  }))
  .handler((_, data) => ({ id: '1', ...data }))
  .callable();
 
// Safe even with untrusted input
createUser(JSON.parse(untrustedInput));
// If invalid: { ok: false, error: { kind: 'VALIDATION_ERROR', ... } }

2. Error Handling

Plain TypeScript: Unpredictable exceptions

function getUser(id: string): User {
  const user = db.find(id);
  if (!user) throw new Error('Not found');  // Caller might forget to catch
  return user;
}
 
// Easy to forget error handling
const user = getUser('123');  // Might crash!

Zagora: Predictable results

const getUser = zagora()
  .input(z.string())
  .errors({ NOT_FOUND: z.object({ id: z.string() }) })
  .handler(({ errors }, id) => {
    const user = db.find(id);
    if (!user) throw errors.NOT_FOUND({ id });
    return user;
  })
  .callable();
 
// Caller must handle both cases
const result = getUser('123');
if (result.ok) {
  // use result.data
} else {
  // handle result.error
}

3. Default Values

Plain TypeScript: Manual defaults

function greet(
  name: string,
  greeting: string = 'Hello',
  punctuation: string = '!'
) {
  return `${greeting}, ${name}${punctuation}`;
}

Zagora: Schema-driven defaults

const greet = zagora()
  .input(z.tuple([
    z.string(),
    z.string().default('Hello'),
    z.string().default('!')
  ]))
  .handler((_, name, greeting, punctuation) => {
    return `${greeting}, ${name}${punctuation}`;
  })
  .callable();
 
// Same behavior, but with validation too
greet('World');  // "Hello, World!"

4. Type Inference

Plain TypeScript: Manual annotations

interface UserInput {
  name: string;
  email: string;
  age?: number;
}
 
interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}
 
function createUser(input: UserInput): User {
  return {
    id: crypto.randomUUID(),
    name: input.name,
    email: input.email,
    age: input.age ?? 18
  };
}

Zagora: Inferred from schemas

const createUser = zagora()
  .input(z.object({
    name: z.string(),
    email: z.string().email(),
    age: z.number().default(18)
  }))
  .output(z.object({
    id: z.string(),
    name: z.string(),
    email: z.string(),
    age: z.number()
  }))
  .handler((_, input) => ({
    id: crypto.randomUUID(),
    ...input
  }))
  .callable();
 
// Types are inferred from schemas - no manual interface definitions

5. Dependency Injection

Plain TypeScript: Prop drilling or class patterns

class UserService {
  constructor(
    private db: Database,
    private logger: Logger,
    private cache: Cache
  ) {}
 
  async getUser(id: string) {
    this.logger.log('Fetching user');
    // ...
  }
}

Zagora: Built-in context

const getUser = zagora()
  .context({ db: myDb, logger: myLogger })
  .input(z.string())
  .handler(({ context }, id) => {
    context.logger.log('Fetching user');
    return context.db.find(id);
  })
  .callable();
 
// Override for testing
const testGetUser = getUser.callable({
  context: { db: mockDb, logger: mockLogger }
});

What You Get with Zagora

  1. Runtime safety - Invalid data is caught before it causes problems
  2. Predictable errors - Never crash from unhandled exceptions
  3. Type inference - Define once in schema, get types everywhere
  4. Less boilerplate - Defaults, validation, and error handling built-in
  5. Testability - Easy dependency injection via context

What Plain TypeScript Is Better For

  • Simple utility functions with no validation needs
  • Performance-critical hot paths (Zagora adds overhead)
  • When you're already using another validation solution
  • One-off scripts where robustness isn't critical

Philosophy

Plain TypeScript gives you types but trusts your code to be correct. Zagora adds a runtime safety net with validation, structured errors, and predictable results.

Choose Zagora when reliability and type-safety at runtime matter.