Type Safety

MoroJS provides end-to-end type safety throughout your entire API, from request validation to response serialization. Learn how TypeScript integration makes your APIs more reliable and maintainable.

Why Type Safety Matters

Type safety in APIs prevents entire categories of runtime errors and provides excellent developer experience:

  • Catch errors at compile time, not in production
  • IntelliSense and autocompletion for all API data
  • Automatic validation of request/response schemas
  • Self-documenting code that's easier to maintain

Request Type Safety

MoroJS automatically infers types from your route patterns and validation schemas:

Automatic Parameter Type Inference

typescript

1import { createApp } from '@morojs/moro';
2
3const app = createApp();
4
5// Parameters are automatically typed as strings
6app.get('/users/:userId/posts/:postId', ({ params }) => {
7  // params.userId: string
8  // params.postId: string
9  console.log(params.userId); // ✅ TypeScript knows this is a string
10  console.log(params.nonExistent); // ❌ TypeScript error
11  
12  return { userId: params.userId, postId: params.postId };
13});

Query Parameter Types

typescript

1app.get('/search', ({ query }) => {
2  // All query parameters are automatically available
3  // query.q: string | undefined
4  // query.limit: string | undefined
5  // query.page: string | undefined
6  
7  const searchTerm = query.q || '';
8  const limit = parseInt(query.limit || '10');
9  const page = parseInt(query.page || '1');
10  
11  return { searchTerm, limit, page };
12});

Schema-Based Validation with Zod

Define schemas once and get both runtime validation and compile-time types:

Request Body Validation

typescript

1import { createApp } from '@morojs/moro';
2import { z } from 'zod';
3
4const CreateUserSchema = z.object({
5  name: z.string().min(1).max(100),
6  email: z.string().email(),
7  age: z.number().min(18).max(120),
8  role: z.enum(['user', 'admin']).default('user')
9});
10
11// TypeScript automatically infers the type from the schema
12type CreateUserRequest = z.infer<typeof CreateUserSchema>;
13
14app.post('/users', {
15  body: CreateUserSchema,
16  handler: ({ body }) => {
17    // body is fully typed and validated!
18    // body.name: string
19    // body.email: string  
20    // body.age: number
21    // body.role: 'user' | 'admin'
22    
23    console.log(`Creating user: ${body.name}`);
24    
25    // TypeScript prevents you from accessing invalid properties
26    // console.log(body.invalidProp); // ❌ TypeScript error
27    
28    return { success: true, user: body };
29  }
30});

Parameter Validation

typescript

1const UserParamsSchema = z.object({
2  userId: z.string().uuid()
3});
4
5app.get('/users/:userId', {
6  params: UserParamsSchema,
7  handler: ({ params }) => {
8    // params.userId: string (validated as UUID)
9    // TypeScript knows it's a valid UUID string
10    return { userId: params.userId };
11  }
12});

Response Type Safety

Define response schemas to ensure your API returns consistent data:

Response Schema Definition

typescript

1const UserResponseSchema = z.object({
2  id: z.string().uuid(),
3  name: z.string(),
4  email: z.string().email(),
5  role: z.enum(['user', 'admin']),
6  createdAt: z.string().datetime(),
7  updatedAt: z.string().datetime()
8});
9
10const ErrorResponseSchema = z.object({
11  error: z.string(),
12  message: z.string(),
13  code: z.number()
14});
15
16app.get('/users/:userId', {
17  params: UserParamsSchema,
18  response: {
19    200: UserResponseSchema,
20    404: ErrorResponseSchema,
21    500: ErrorResponseSchema
22  },
23  handler: async ({ params }) => {
24    const user = await getUserById(params.userId);
25    
26    if (!user) {
27      // TypeScript ensures this matches ErrorResponseSchema
28      return {
29        status: 404,
30        body: {
31          error: 'NOT_FOUND',
32          message: 'User not found',
33          code: 404
34        }
35      };
36    }
37    
38    // TypeScript ensures this matches UserResponseSchema
39    return {
40      status: 200,
41      body: {
42        id: user.id,
43        name: user.name,
44        email: user.email,
45        role: user.role,
46        createdAt: user.createdAt.toISOString(),
47        updatedAt: user.updatedAt.toISOString()
48      }
49    };
50  }
51});

Context Type Safety

Middleware can add typed data to the request context:

Typed Middleware Context

typescript

1// Define types for your context
2interface AuthContext {
3  user: {
4    id: string;
5    email: string;
6    role: 'user' | 'admin';
7  };
8}
9
10const authMiddleware = async ({ headers }): Promise<AuthContext> => {
11  const token = headers.authorization?.replace('Bearer ', '');
12  if (!token) {
13    throw new Error('No token provided');
14  }
15  
16  const user = await verifyToken(token);
17  return { user };
18};
19
20app.get('/profile', {
21  middleware: [authMiddleware],
22  handler: ({ context }) => {
23    // context.user is fully typed!
24    // context.user.id: string
25    // context.user.email: string
26    // context.user.role: 'user' | 'admin'
27    
28    return {
29      profile: {
30        id: context.user.id,
31        email: context.user.email,
32        role: context.user.role
33      }
34    };
35  }
36});

Best Practices

Do

  • • Define schemas for all inputs and outputs
  • • Use strict TypeScript configuration
  • • Leverage z.infer for type extraction
  • • Create reusable schema components
  • • Use branded types for IDs
  • • Document complex types with JSDoc

Don't

  • • Use 'any' types
  • • Skip validation for external data
  • • Ignore TypeScript errors
  • • Mix validated and unvalidated data
  • • Use type assertions without validation
  • • Forget to handle all response cases

Advanced Type Patterns

Branded Types for Better Safety

typescript

1// Create branded types for different ID types
2type UserId = string & { readonly brand: unique symbol };
3type PostId = string & { readonly brand: unique symbol };
4
5const UserIdSchema = z.string().uuid().transform((val) => val as UserId);
6const PostIdSchema = z.string().uuid().transform((val) => val as PostId);
7
8app.get('/users/:userId/posts/:postId', {
9  params: z.object({
10    userId: UserIdSchema,
11    postId: PostIdSchema
12  }),
13  handler: ({ params }) => {
14    // params.userId: UserId (not just string)
15    // params.postId: PostId (not just string)
16    
17    // TypeScript prevents mixing up different ID types
18    const post = getPost(params.postId, params.userId); // ✅ Correct order
19    // const post = getPost(params.userId, params.postId); // ❌ Type error
20    
21    return { post };
22  }
23});

Conditional Response Types

typescript

1const PaginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
2  z.object({
3    data: z.array(itemSchema),
4    pagination: z.object({
5      page: z.number(),
6      limit: z.number(),
7      total: z.number(),
8      hasMore: z.boolean()
9    })
10  });
11
12app.get('/users', {
13  query: z.object({
14    page: z.coerce.number().default(1),
15    limit: z.coerce.number().min(1).max(100).default(20)
16  }),
17  response: {
18    200: PaginatedResponseSchema(UserResponseSchema)
19  },
20  handler: async ({ query }) => {
21    const users = await getUsers(query.page, query.limit);
22    const total = await getUserCount();
23    
24    return {
25      data: users,
26      pagination: {
27        page: query.page,
28        limit: query.limit,
29        total,
30        hasMore: (query.page * query.limit) < total
31      }
32    };
33  }
34});

Next Steps