Core Concepts
Type Safety
End-to-end type safety from request validation to response serialization. TypeScript automatically knows what properties are available, so there is no guessing and no runtime surprises.
Overview
Define your schema once. Get runtime validation and compile-time types. TypeScript automatically knows what's available.
1import { createApp } from '@morojs/moro';
2import { z } from 'zod';
3
4const CreateUserSchema = z.object({
5 name: z.string().min(1),
6 email: z.string().email(),
7 age: z.number().min(18)
8});
9
10app.post('/users')
11 .body(CreateUserSchema)
12 .handler(({ body }) => {
13 // TypeScript automatically knows:
14 // body.name: string
15 // body.email: string
16 // body.age: number
17
18 // No type assertions needed!
19 return { success: true, user: body };
20 }));Catch errors at compile time, not in production. Get IntelliSense everywhere. Without type safety, you're guessing property names and hoping the API returns what you expect.
Without Types
- Guessing property names
- Runtime errors in production
- No autocomplete or IntelliSense
- Manual type checking everywhere
With MoroJS
- Automatic type inference
- Errors caught at compile time
- Full IntelliSense support
- TypeScript knows what's available
1// Define schema
2const UserSchema = z.object({
3 name: z.string(),
4 email: z.string().email()
5});
6
7// Use in route - types are automatic!
8app.post('/users')
9 .body(UserSchema)
10 .handler(({ body }) => {
11 // body is fully typed!
12 return { user: body };
13 }));Catch Errors Early
TypeScript catches errors before your code runs. No more production surprises.
Better DX
Full autocomplete and IntelliSense. Write code faster with confidence.
Self-Documenting
Types serve as documentation. See what's available without reading docs.
How It Works
MoroJS automatically infers types from your route patterns, validation schemas, and middleware context. You define your data structure once, and TypeScript knows about it everywhere.
Request Type Safety
Route parameters, query strings, and request bodies are automatically typed based on your route patterns and schemas.
1import { createApp } from '@morojs/moro';
2
3const app = await 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});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});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
11app.post('/users')
12 .body(CreateUserSchema)
13 .handler(({ body }) => {
14 // body is fully typed and validated!
15 // body.name: string
16 // body.email: string
17 // body.age: number
18 // body.role: 'user' | 'admin'
19
20 console.log(`Creating user: ${body.name}`);
21
22 // TypeScript prevents you from accessing invalid properties
23 // console.log(body.invalidProp); // ❌ TypeScript error
24
25 return { success: true, user: body };
26 }));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 }));Response Type Safety
MoroJS validates inbound data — body, query, params, and headers. Responses are not validated at runtime; instead you get compile-time safety by typing the handler's return with z.infer of your response schema. TypeScript then rejects any response that doesn't match, at zero runtime cost.
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
16// Derive the response types from the schemas, then annotate the handler's
17// return. TypeScript enforces the contract at compile time.
18type UserResponse = z.infer<typeof UserResponseSchema>;
19type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
20
21app.get('/users/:userId')
22 .params(UserParamsSchema)
23 .handler(async (req, res): Promise<UserResponse | ErrorResponse> => {
24 const user = await getUserById(req.params.userId);
25
26 if (!user) {
27 // Non-200 statuses are set on res; the returned object is the body.
28 // Must match ErrorResponseSchema or this won't compile.
29 res.status(404);
30 return { error: 'NOT_FOUND', message: 'User not found', code: 404 };
31 }
32
33 // Whatever the handler returns becomes the JSON body.
34 // Must match UserResponseSchema or this won't compile.
35 return {
36 id: user.id,
37 name: user.name,
38 email: user.email,
39 role: user.role,
40 createdAt: user.createdAt.toISOString(),
41 updatedAt: user.updatedAt.toISOString()
42 };
43 });Request State Type Safety
Middleware uses the standard (req, res, next) contract and attaches state directly to the request. Declare that state once by extending HttpRequest, and every handler that receives it is typed.
1import type { HttpRequest, Middleware } from '@morojs/moro';
2
3// Declare the state your middleware attaches
4interface AuthedRequest extends HttpRequest {
5 user: {
6 id: string;
7 email: string;
8 role: 'user' | 'admin';
9 };
10}
11
12const authMiddleware: Middleware = async (req, res, next) => {
13 const token = req.headers.authorization?.replace('Bearer ', '');
14 if (!token) {
15 return res.status(401).json({ error: 'No token provided' });
16 }
17
18 (req as AuthedRequest).user = await verifyToken(token);
19 next();
20};
21
22app.get('/profile')
23 .use(authMiddleware)
24 .handler((req: AuthedRequest) => {
25 // req.user is fully typed:
26 // req.user.id: string
27 // req.user.email: string
28 // req.user.role: 'user' | 'admin'
29 return {
30 profile: {
31 id: req.user.id,
32 email: req.user.email,
33 role: req.user.role
34 }
35 };
36 });Advanced Type Patterns
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((req) => {
14 // req.params.userId: UserId (not just string)
15 // req.params.postId: PostId (not just string)
16
17 // TypeScript prevents mixing up different ID types
18 const post = getPost(req.params.postId, req.params.userId); // ✅ Correct order
19 // const post = getPost(req.params.userId, req.params.postId); // ❌ Type error
20
21 return { post };
22 });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
12type PaginatedUsers = z.infer<ReturnType<typeof PaginatedResponseSchema<typeof UserResponseSchema>>>;
13
14app.get('/users')
15 .query(z.object({
16 page: z.coerce.number().default(1),
17 limit: z.coerce.number().min(1).max(100).default(20)
18 }))
19 .handler(async (req): Promise<PaginatedUsers> => {
20 const users = await getUsers(req.query.page, req.query.limit);
21 const total = await getUserCount();
22
23 return {
24 data: users,
25 pagination: {
26 page: req.query.page,
27 limit: req.query.limit,
28 total,
29 hasMore: (req.query.page * req.query.limit) < total
30 }
31 };
32 });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