← Back to DevBytes

Elysia TypeScript: Strongly Typed Applications

Understanding Elysia's Type System

Elysia is a high-performance HTTP framework built for Bun, designed with TypeScript at its core. Unlike traditional Node.js frameworks that bolt on type definitions as an afterthought, Elysia treats types as a first-class citizen. Its schema-driven architecture allows you to define the shape of incoming requests and outgoing responses declaratively, and the framework automatically infers TypeScript types from those definitions. This means your route handlers, middleware, and even client-side code benefit from end-to-end type safety without manual type annotations or tedious validation boilerplate.

At the heart of Elysia's type system sits TypeBox, a JSON-schema-based type builder that bridges runtime validation and compile-time TypeScript inference. When you define a schema for a request body, query parameter, or header, Elysia simultaneously generates a JSON Schema for validation at runtime and a precise TypeScript type for your handler's input parameter. This dual nature eliminates the all-too-common disconnect between what you validate and what TypeScript believes your data looks like.

Why Strong Typing Matters in Web Applications

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In loosely typed JavaScript frameworks, request data arrives as any or unknown. Developers must manually cast, validate, or simply trust that the client sent the correct shape. This leads to:

Elysia addresses each of these by making the schema the single source of truth. Your route definition becomes a contract. TypeScript enforces that contract at compile time. The framework enforces it at runtime. And because Elysia can generate OpenAPI 3.x specifications from your route schemas automatically, your API documentation stays perfectly synchronized with your implementation — no manual upkeep required.

Getting Started: Setting Up an Elysia Project

First, ensure you have Bun installed. Create a new project and add Elysia:

bun create elysia my-api
cd my-api
# Elysia is already included in the template
# If starting from scratch:
# bun add elysia

The scaffolded project gives you a basic src/index.ts file. Let's examine the foundation before diving into strong typing patterns:

import { Elysia } from 'elysia';

const app = new Elysia()
  .get('/', () => 'Hello World')
  .listen(3000);

console.log(`Server running at http://localhost:3000`);

export type App = typeof app;

Notice the export type App = typeof app line. This is a critical pattern: by exporting the type of your Elysia instance, you enable other parts of your codebase — or even separate client packages — to reference the exact shape of your API with full type inference. We'll explore this powerful capability later.

Defining Typed Routes with Schema Validation

The simplest typed route uses Elysia's schema property inside a route handler. Let's build a POST endpoint that accepts a user creation payload:

import { Elysia, t } from 'elysia';

const app = new Elysia()
  .post('/users', ({ body }) => {
    // body is fully typed — no casting needed
    const { name, email, age } = body;
    return {
      id: crypto.randomUUID(),
      name,
      email,
      age,
      createdAt: new Date().toISOString()
    };
  }, {
    schema: {
      body: t.Object({
        name: t.String({ minLength: 2, maxLength: 100 }),
        email: t.String({ format: 'email' }),
        age: t.Number({ minimum: 0, maximum: 150 }),
      })
    }
  })
  .listen(3000);

export type App = typeof app;

Here's what's happening under the hood:

You get autocompletion inside the handler, compile-time errors if you access a non-existent property, and runtime safety — all from a single schema definition.

Typed Path Parameters and Query Strings

The schema system extends beyond request bodies. You can type path parameters, query strings, headers, and even cookies:

import { Elysia, t } from 'elysia';

const app = new Elysia()
  .get('/users/:id', ({ params, query }) => {
    // params.id is typed as number (coerced from the URL string)
    // query.include is typed as string | undefined
    const { id } = params;
    const { include } = query;

    // Fetch user logic here...
    return {
      id,
      name: 'John Doe',
      // include determines if we return extra fields
      ...(include === 'posts' && { posts: [] })
    };
  }, {
    schema: {
      params: t.Object({
        id: t.Numeric() // coerces string to number automatically
      }),
      query: t.Object({
        include: t.Optional(t.String())
      })
    }
  })
  .listen(3000);

export type App = typeof app;

The t.Numeric() type is particularly useful — it accepts a string from the URL path but coerces it to a number for your handler, so params.id is already a number when you use it. No manual parseInt calls cluttering your business logic.

Response Typing and Status Codes

Elysia also allows you to declare response schemas. This serves two purposes: it validates outgoing data to ensure you're not accidentally leaking fields or sending malformed responses, and it feeds into automatic OpenAPI generation:

import { Elysia, t } from 'elysia';

const app = new Elysia()
  .post('/auth/login', ({ body }) => {
    // Authentication logic...
    const token = 'generated-jwt-token';
    return {
      token,
      expiresIn: 3600
    };
  }, {
    schema: {
      body: t.Object({
        username: t.String(),
        password: t.String({ minLength: 8 })
      }),
      response: {
        200: t.Object({
          token: t.String(),
          expiresIn: t.Number()
        }),
        401: t.Object({
          error: t.String()
        })
      }
    }
  })
  .listen(3000);

export type App = typeof app;

By specifying multiple status code responses, Elysia knows exactly what your endpoint can return. The OpenAPI generator uses this to document all possible response shapes. In development, if your handler accidentally returns a shape that doesn't match any declared response schema, Elysia warns you — catching bugs before they reach production.

Using the Response Type Utility

Elysia exports a Static utility that extracts the TypeScript type from any schema definition. This is invaluable when you want to share types between your backend and frontend:

import { Elysia, t, Static } from 'elysia';

// Define schemas as reusable constants
const UserSchema = t.Object({
  id: t.String({ format: 'uuid' }),
  name: t.String(),
  email: t.String({ format: 'email' }),
  createdAt: t.String()
});

// Extract the TypeScript type
type User = Static<typeof UserSchema>;

// Now User is a proper type you can use anywhere:
// { id: string; name: string; email: string; createdAt: string }

const app = new Elysia()
  .get('/users/:id', ({ params }) => {
    const user: User = {
      id: params.id,
      name: 'Alice',
      email: 'alice@example.com',
      createdAt: new Date().toISOString()
    };
    return user;
  }, {
    schema: {
      params: t.Object({ id: t.String() }),
      response: { 200: UserSchema }
    }
  })
  .listen(3000);

export type App = typeof app;
export { UserSchema, type User };

By exporting both the schema object and its derived type, you create a shareable contract. A frontend team consuming your API can import User and UserSchema to validate their own requests or mock data against the exact same definition your server uses.

Leveraging Elysia's Type System Across Your Codebase

One of Elysia's most powerful features is the ability to export the type of your entire application instance. Combined with the Eden Treaty plugin (or the newer Eden client), you can achieve type-safe client-server communication without code generation:

// server.ts
import { Elysia, t } from 'elysia';

const app = new Elysia()
  .get('/health', () => ({ status: 'ok' }), {
    response: { 200: t.Object({ status: t.String() }) }
  })
  .get('/users/:id', ({ params }) => ({
    id: params.id,
    name: 'Bob'
  }), {
    schema: {
      params: t.Object({ id: t.String() })
    },
    response: { 200: t.Object({ id: t.String(), name: t.String() }) }
  })
  .listen(3001);

// Export the type — this captures ALL routes, schemas, and responses
export type App = typeof app;
export default app;

Now, in a separate client file (or even a separate package in a monorepo), you can use Elysia's type inference to call your API with full autocompletion:

// client.ts — uses the exported App type
import { treaty } from '@elysiajs/eden';
import type { App } from './server'; // import ONLY the type

const client = treaty<App>('http://localhost:3001');

// Full type safety on the client side!
const health = await client.health.get();
// health.data.status is typed as string

const user = await client.users({ id: '123' }).get();
// user.data.id is typed as string
// user.data.name is typed as string

// TypeScript catches mistakes:
// await client.users({}).get();          // ERROR: 'id' is required
// await client.nonexistent.get();       // ERROR: no such route

The treaty function takes the App type as a generic parameter and derives the entire API surface from it. There's no code generation step, no OpenAPI client generation pipeline — just TypeScript inference working its magic. This pattern, often called "type-safe RPC," gives you the ergonomics of a typed SDK with zero maintenance overhead.

Middleware, Guards, and Derived Types

Elysia's middleware system preserves and extends types through the request pipeline. When you add a guard or transform middleware, the types flow through to subsequent handlers:

import { Elysia, t } from 'elysia';

// A guard that verifies an auth token and derives user info
const authGuard = new Elysia()
  .derive(({ headers }) => {
    const token = headers.authorization?.replace('Bearer ', '');
    // Verify token... (simplified here)
    if (!token || token !== 'valid-secret') {
      throw new Error('Unauthorized');
    }
    return {
      currentUser: {
        id: 'user-1',
        role: 'admin'
      }
    };
  });

const app = new Elysia()
  .use(authGuard)
  .get('/admin/dashboard', ({ currentUser }) => {
    // currentUser is typed from the derive() return value!
    // TypeScript knows: { id: string; role: string }
    if (currentUser.role !== 'admin') {
      throw new Error('Forbidden');
    }
    return { message: `Welcome ${currentUser.id}` };
  })
  .listen(3000);

export type App = typeof app;

The derive method adds properties to the request context, and TypeScript propagates those types through every subsequent handler that uses the same Elysia instance. This means your middleware can enrich the request with typed data — user sessions, database connections, feature flags — and handlers receive those properties with precise types, not as opaque any objects.

Typed Plugins and Extensions

When you build reusable Elysia plugins, you can declare their types explicitly so consumers get full type safety:

import { Elysia, t } from 'elysia';

// A plugin that adds a typed database helper
const dbPlugin = new Elysia({ name: 'db' })
  .derive(() => ({
    db: {
      findUser: async (id: string) => ({
        id,
        name: 'Found User',
        email: 'found@example.com'
      }),
      createUser: async (data: { name: string; email: string }) => ({
        id: crypto.randomUUID(),
        ...data
      })
    }
  }));

// Consumer application
const app = new Elysia()
  .use(dbPlugin) // dbPlugin provides typed db methods
  .get('/users/:id', async ({ params, db }) => {
    // db.findUser is typed — returns Promise<{ id, name, email }>
    const user = await db.findUser(params.id);
    return user;
  })
  .listen(3000);

export type App = typeof app;

The plugin's derive return type flows into the consumer's handler context. This composability lets you build sophisticated, type-safe infrastructure layers — database access, authentication, logging — that integrate seamlessly with Elysia's core type system.

Automatic OpenAPI Generation

Because every route schema contains complete type information in JSON Schema format, Elysia can generate a full OpenAPI 3.x specification automatically. You simply add the Swagger plugin:

import { Elysia, t } from 'elysia';
import { swagger } from '@elysiajs/swagger';

const app = new Elysia()
  .use(swagger({
    documentation: {
      info: {
        title: 'My Typed API',
        version: '1.0.0',
        description: 'A fully typed Elysia API with auto-generated docs'
      }
    }
  }))
  .get('/users', () => [
    { id: '1', name: 'Alice', email: 'alice@example.com' }
  ], {
    response: {
      200: t.Array(
        t.Object({
          id: t.String(),
          name: t.String(),
          email: t.String({ format: 'email' })
        })
      )
    },
    detail: {
      summary: 'List all users',
      tags: ['Users']
    }
  })
  .post('/users', ({ body }) => ({
    id: crypto.randomUUID(),
    ...body,
    createdAt: new Date().toISOString()
  }), {
    schema: {
      body: t.Object({
        name: t.String(),
        email: t.String({ format: 'email' })
      }),
      response: {
        201: t.Object({
          id: t.String(),
          name: t.String(),
          email: t.String(),
          createdAt: t.String()
        })
      }
    },
    detail: {
      summary: 'Create a new user',
      tags: ['Users']
    }
  })
  .listen(3000);

console.log('Swagger docs available at http://localhost:3000/swagger');

export type App = typeof app;

Visit /swagger in your browser and you'll see a fully interactive Swagger UI with all your routes, their request schemas, response schemas, and even example values inferred from your type definitions. The documentation is guaranteed to match your implementation because it's derived from the same schemas that validate your actual requests and responses.

Advanced: Conditional Types and Route Groups

For larger applications, you'll want to organize routes into logical groups while preserving type safety. Elysia's group method lets you prefix routes and apply shared guards, all while maintaining the unified type graph:

import { Elysia, t } from 'elysia';

const app = new Elysia()
  // Public routes — no auth required
  .group('/api/v1', app => app
    .get('/health', () => ({ status: 'ok' }), {
      response: { 200: t.Object({ status: t.String() }) }
    })
    .post('/contact', ({ body }) => {
      // Process contact form...
      return { success: true };
    }, {
      schema: {
        body: t.Object({
          name: t.String(),
          message: t.String({ minLength: 10 })
        })
      }
    })
  )
  // Admin routes — grouped with auth middleware
  .group('/api/v1/admin', app => app
    .derive(({ headers }) => {
      // Auth check...
      return { adminUser: { id: 'admin-1' } };
    })
    .get('/stats', ({ adminUser }) => {
      // adminUser is typed here
      return {
        totalUsers: 1234,
        activeToday: 56,
        requestedBy: adminUser.id
      };
    }, {
      response: {
        200: t.Object({
          totalUsers: t.Number(),
          activeToday: t.Number(),
          requestedBy: t.String()
        })
      }
    })
  )
  .listen(3000);

export type App = typeof app;

Each group inherits types from its parent context and can add new derived properties that remain scoped to that group. The exported App type captures all routes across all groups, so your Eden client still sees the complete API surface.

Best Practices for Strongly Typed Elysia Applications

1. Define Schemas as Reusable Constants

Extract your common schemas into a dedicated module. This keeps routes clean and lets you compose complex types:

// schemas.ts
import { t } from 'elysia';

export const UserBodySchema = t.Object({
  name: t.String({ minLength: 2 }),
  email: t.String({ format: 'email' }),
  role: t.Union([t.Literal('admin'), t.Literal('user')])
});

export const UserResponseSchema = t.Object({
  id: t.String({ format: 'uuid' }),
  name: t.String(),
  email: t.String(),
  role: t.String(),
  createdAt: t.String()
});

export const ErrorSchema = t.Object({
  error: t.String(),
  details: t.Optional(t.String())
});

// Use in routes:
// import { UserBodySchema, UserResponseSchema, ErrorSchema } from './schemas';

2. Export Your App Type Religiously

Always end your server entry point with export type App = typeof app. This single line unlocks type-safe client generation, end-to-end testing with type guarantees, and monorepo-wide type sharing. It costs nothing at runtime (types are erased) but pays enormous dividends in developer experience.

3. Use Derivation for Dependency Injection

Instead of importing database clients or services directly into route files, use derive to attach them to the request context. This makes testing easier (you can swap derived values in tests) and keeps route handlers focused on business logic:

// dependencies.ts
import { Elysia } from 'elysia';
import { Database } from './db'; // your database client

export const dbPlugin = new Elysia()
  .derive(() => ({
    db: new Database(process.env.DATABASE_URL!)
  }));

// routes/users.ts
import { Elysia, t } from 'elysia';
import { UserResponseSchema } from '../schemas';

export const userRoutes = new Elysia()
  .get('/users', async ({ db }) => {
    const users = await db.query('SELECT * FROM users');
    return users;
  }, {
    response: { 200: t.Array(UserResponseSchema) }
  });

4. Validate Responses in Development

Enable Elysia's response validation during development to catch mismatches between your handler's actual return value and the declared response schema. This is off by default in production for performance, but invaluable during development:

const app = new Elysia({
  // Enable strict response validation in development
  strict: process.env.NODE_ENV !== 'production'
})

5. Keep Business Logic Separate from Route Definitions

While Elysia allows inline handlers, complex logic benefits from separation. The types still flow through:

// services/userService.ts
import type { Static } from 'elysia';
import { UserBodySchema, UserResponseSchema } from '../schemas';

type UserBody = Static<typeof UserBodySchema>;
type UserResponse = Static<typeof UserResponseSchema>;

export async function createUser(input: UserBody): Promise<UserResponse> {
  // Business logic, validation, database calls...
  return {
    id: crypto.randomUUID(),
    name: input.name,
    email: input.email,
    role: input.role,
    createdAt: new Date().toISOString()
  };
}

// routes/users.ts
import { Elysia } from 'elysia';
import { UserBodySchema, UserResponseSchema } from '../schemas';
import { createUser } from '../services/userService';

export const userRoutes = new Elysia()
  .post('/users', async ({ body }) => {
    return await createUser(body);
  }, {
    schema: {
      body: UserBodySchema,
      response: { 201: UserResponseSchema }
    }
  });

This pattern gives you type-safe service functions that can be tested independently, while the route layer handles HTTP concerns and validation.

6. Leverage TypeScript's Strict Mode

Elysia works best with TypeScript's strict checks enabled. In your tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

With strict mode, TypeScript will flag any places where your handler's types don't align with your schemas, catching bugs at compile time rather than in production logs.

Conclusion

Elysia's approach to strong typing represents a significant evolution in TypeScript web framework design. By making JSON Schema definitions the single source of truth — simultaneously powering runtime validation, TypeScript type inference, automatic OpenAPI documentation, and type-safe client communication — Elysia eliminates entire categories of bugs and maintenance burdens that plague traditional frameworks. The ability to export your app's type and consume it via Eden Treaty gives you the ergonomics of a hand-written SDK without any code generation or synchronization overhead. Combined with TypeBox's expressive schema builder and Elysia's composable plugin architecture, you can build web applications where the compiler, the runtime validator, the API documentation, and the client library all agree on exactly what your data looks like. That's not just developer convenience — it's a fundamentally more reliable way to build and ship web APIs.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles