Skip to content

feat(types): support generics #284

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open

feat(types): support generics #284

wants to merge 18 commits into from

Conversation

naorpeled
Copy link
Collaborator

@naorpeled naorpeled commented Feb 6, 2025

Basic Type-Safe Setup

import { API, Request, Response, ALBContext, APIGatewayContext, APIGatewayV2Context } from 'lambda-api';
import { ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';

// Initialize with type inference
const api = new API();

Type-Safe Request Handlers

ALB Handler Example

interface UserData {
  id: string;
  name: string;
  email: string;
}

// Type-safe ALB request handler
api.get<UserData, ALBContext>('/users', (req, res) => {
  // req.requestContext is typed as ALBContext
  console.log(req.requestContext.elb.targetGroupArn);
  
  // Type-safe response
  res.json({
    id: '123',
    name: 'John Doe',
    email: 'john@example.com'
  });
});

API Gateway v1 Handler Example

// Type-safe API Gateway v1 request handler
api.post<UserData, APIGatewayContext>('/users', (req, res) => {
  // req.requestContext is typed as APIGatewayContext
  console.log(req.requestContext.requestId);
  console.log(req.requestContext.identity.sourceIp);
  
  res.json({
    id: req.requestContext.requestId,
    name: req.body.name,
    email: req.body.email
  });
});

API Gateway v2 Handler Example

// Type-safe API Gateway v2 request handler
api.put<UserData, APIGatewayV2Context>('/users/:id', (req, res) => {
  // req.requestContext is typed as APIGatewayV2Context
  console.log(req.requestContext.http.sourceIp);
  
  res.json({
    id: req.params.id,
    name: req.body.name,
    email: req.body.email
  });
});

Type-Safe Middleware

Source-Agnostic Middleware

import { Middleware, isApiGatewayContext, isApiGatewayV2Context, isAlbContext } from 'lambda-api';

const sourceAgnosticMiddleware: Middleware = (req, res, next) => {
  // Type guards help narrow down the request context type
  if (isAlbContext(req.requestContext)) {
    // ALB specific logic
    console.log(req.requestContext.elb.targetGroupArn);
  } else if (isApiGatewayV2Context(req.requestContext)) {
    // API Gateway v2 specific logic
    console.log(req.requestContext.http.sourceIp);
  } else if (isApiGatewayContext(req.requestContext)) {
    // API Gateway v1 specific logic
    console.log(req.requestContext.identity.sourceIp);
  }
  
  next();
};

api.use(sourceAgnosticMiddleware);

Source-Specific Middleware

// ALB-specific middleware
const albMiddleware: Middleware<any, ALBContext> = (req, res, next) => {
  // req.requestContext is typed as ALBContext
  console.log(req.requestContext.elb.targetGroupArn);
  next();
};

// API Gateway v2 specific middleware
const apiGwV2Middleware: Middleware<any, APIGatewayV2Context> = (req, res, next) => {
  // req.requestContext is typed as APIGatewayV2Context
  console.log(req.requestContext.http.sourceIp);
  next();
};

Type-Safe Error Handling

import { ErrorHandlingMiddleware } from 'lambda-api';

const errorHandler: ErrorHandlingMiddleware = (error, req, res, next) => {
  if (isAlbContext(req.requestContext)) {
    // ALB specific error handling
    res.status(500).json({
      message: error.message,
      targetGroup: req.requestContext.elb.targetGroupArn
    });
  } else {
    // Default error handling
    res.status(500).json({
      message: error.message
    });
  }
};

api.use(errorHandler);

Advanced Type-Safe Examples

Custom Request Types

interface CustomQuery {
  filter?: string;
  page?: string;
}

interface CustomParams {
  userId: string;
}

interface CustomBody {
  name: string;
  email: string;
}

// Fully typed request handler
api.get<
  UserData,
  ALBContext,
  CustomQuery,
  CustomParams,
  CustomBody
>('/users/:userId', (req, res) => {
  // All properties are properly typed
  const { filter, page } = req.query;
  const { userId } = req.params;
  const { name, email } = req.body;
  
  res.json({
    id: userId,
    name,
    email
  });
});

Response Type Extensions

// Extend Response interface with custom methods
declare module 'lambda-api' {
  interface Response {
    sendWithTimestamp?: (data: any) => void;
  }
}

const responseEnhancer: Middleware = (req, res, next) => {
  res.sendWithTimestamp = (data: any) => {
    res.json({
      ...data,
      timestamp: Date.now()
    });
  };
  next();
};

api.use(responseEnhancer);

// Use custom response method
api.get('/users', (req, res) => {
  res.sendWithTimestamp({ name: 'John' });
});

Using Built-in Auth Property

interface AuthInfo {
  userId: string;
  roles: string[];
  type: 'Bearer' | 'Basic' | 'OAuth' | 'Digest' | 'none';
  value: string | null;
}

function hasAuth(req: Request): req is Request & { auth: AuthInfo } {
  return 'auth' in req && req.auth?.type !== undefined;
}

api.get('/protected', (req, res) => {
  if (hasAuth(req)) {
    // req.auth is now typed as AuthInfo
    const { userId, roles } = req.auth;
    res.json({ userId, roles });
  } else {
    res.status(401).json({ message: 'Unauthorized' });
  }
});

Running the API

// Type-safe run method
export const handler = async (
  event: ALBEvent | APIGatewayProxyEvent | APIGatewayProxyEventV2,
  context: any
) => {
  return api.run(event, context);
};

Type Guards Usage

import {
  isAlbContext,
  isAlbEvent,
  isAlbRequest,
  isApiGatewayContext,
  isApiGatewayEvent,
  isApiGatewayRequest,
  isApiGatewayV2Context,
  isApiGatewayV2Event,
  isApiGatewayV2Request
} from 'lambda-api';

api.use((req, res, next) => {
  // Event type guards
  if (isAlbEvent(req.app._event)) {
    // ALB specific logic
  }
  
  // Context type guards
  if (isAlbContext(req.requestContext)) {
    // ALB specific logic
  }
  
  // Request type guards
  if (isAlbRequest(req)) {
    // ALB specific logic
  }
  
  next();
});

Best Practices

  1. Always specify response types for better type inference:
interface ResponseType {
  message: string;
  code: number;
}

api.get<ResponseType>('/status', (req, res) => {
  res.json({
    message: 'OK',
    code: 200
  });
});
  1. Use type guards for source-specific logic:
api.use((req, res, next) => {
  if (isAlbContext(req.requestContext)) {
    // ALB-specific logging
    console.log(`ALB Request to ${req.requestContext.elb.targetGroupArn}`);
  }
  next();
});
  1. Leverage TypeScript's type inference with middleware:
const typedMiddleware: Middleware<ResponseType, ALBContext> = (req, res, next) => {
  // Full type information available
  next();
};
  1. Use source-specific error handling:
api.use((error, req, res, next) => {
  const baseError = {
    message: error.message,
    timestamp: new Date().toISOString()
  };

  if (isAlbContext(req.requestContext)) {
    res.status(500).json({
      ...baseError,
      targetGroup: req.requestContext.elb.targetGroupArn
    });
  } else if (isApiGatewayV2Context(req.requestContext)) {
    res.status(500).json({
      ...baseError,
      stage: req.requestContext.stage
    });
  } else {
    res.status(500).json(baseError);
  }
});

Issues

implements #276
and closes #244

@vandrade-git
Copy link
Contributor

This seems like a great change.

I've just tried on a project I have and noticed a few things. Given:

api.get<Response, ALBContext>('/health', async (req, res) => {
  console.log(req.requestContext.elb); <-- also type any, any, any

  res.json({ status: 'ok' });
});

both req and res have an implicit type any so the type checking does not seem to be doing what is described in the README.

src/index.ts:20:52 - error TS7006: Parameter 'req' implicitly has an 'any' type.
20 api.get<Response, ALBContext>('/health', async (req, res) => {

src/index.ts:20:57 - error TS7006: Parameter 'res' implicitly has an 'any' type.
20 api.get<Response, ALBContext>('/health', async (req, res) => {

This seems to be a limitation of the type

...
(
  | Middleware<TResponse, TContext, TQuery, TParams, TBody>
  | HandlerFunction<TResponse, TContext, TQuery, TParams, TBody>
)
...

Something like:

const health: HandlerFunction<object, ALBContext> = async (req, res) => {
  console.log(req.requestContext.elb);

  res.json({ status: 'ok' });
};
// public health endpoint
api.get<Response, ALBContext>('/health', health);

seems to over fine but it is a bit more cumbersome.

@naorpeled
Copy link
Collaborator Author

naorpeled commented Feb 9, 2025

This seems like a great change.

I've just tried on a project I have and noticed a few things. Given:

api.get<Response, ALBContext>('/health', async (req, res) => {
  console.log(req.requestContext.elb); <-- also type any, any, any

  res.json({ status: 'ok' });
});

both req and res have an implicit type any so the type checking does not seem to be doing what is described in the README.

src/index.ts:20:52 - error TS7006: Parameter 'req' implicitly has an 'any' type.
20 api.get<Response, ALBContext>('/health', async (req, res) => {

src/index.ts:20:57 - error TS7006: Parameter 'res' implicitly has an 'any' type.
20 api.get<Response, ALBContext>('/health', async (req, res) => {

This seems to be a limitation of the type

...
(
  | Middleware<TResponse, TContext, TQuery, TParams, TBody>
  | HandlerFunction<TResponse, TContext, TQuery, TParams, TBody>
)
...

Something like:

const health: HandlerFunction<object, ALBContext> = async (req, res) => {
  console.log(req.requestContext.elb);

  res.json({ status: 'ok' });
};
// public health endpoint
api.get<Response, ALBContext>('/health', health);

seems to over fine but it is a bit more cumbersome.

I see, I'll dig into it in the next few days.
Thanks for the feedback, very appreciated!

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Feature request] Typescript - Support generic request / response types
2 participants