Skip to content

feat(types): support generics#284

Open
naorpeled wants to merge 19 commits intomainfrom
feat/types/support-alb
Open

feat(types): support generics#284
naorpeled wants to merge 19 commits intomainfrom
feat/types/support-alb

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!

@naorpeled
Copy link
Collaborator Author

@copilot continue this PR and make sure CI fully passes and that this feature works

Copy link
Contributor

Copilot AI commented Feb 7, 2026

@naorpeled I've opened a new pull request, #304, to work on those changes. Once the pull request is ready, I'll request review from you.

@naorpeled
Copy link
Collaborator Author

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!

Sorry for the huge delay with this, hopefully I'll be able to finalize this soon

Implement isApiGatewayEvent, isApiGatewayV2Event, isAlbEvent event type guards and isApiGatewayRequest, isApiGatewayV2Request, isAlbRequest request type guards. Re-add ErrorCallback type declaration referenced by getLink, download, and sendFile. Export all 9 type guards from index.js and add comprehensive unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 28, 2026 19:40
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds generic TypeScript support to lambda-api, addressing issues #276 (ALB TypeScript support) and #244 (generic request/response types). It introduces type-safe generics across route handlers, middleware, and error handlers, along with runtime type guards to discriminate between API Gateway v1, API Gateway v2, and ALB request contexts.

Changes:

  • New lib/typeguards.js file exporting runtime type guard functions for API Gateway v1/v2 and ALB contexts, events, and requests
  • index.d.ts extensively updated to add generics (TResponse, TContext, TQuery, TParams, TBody) to Request, Response, Middleware, HandlerFunction, ErrorHandlingMiddleware, and all API methods; exports new context types (ALBContext, APIGatewayContext, APIGatewayV2Context), SourceAgnostic* helper types, and type guard declarations
  • README.md and index.test-d.ts updated with comprehensive TypeScript documentation and new type-level tests

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
lib/typeguards.js New module with runtime type guards for discriminating event/context/request sources
index.js Imports and re-exports the new type guard functions
index.d.ts Full overhaul of TypeScript declarations to support generics and new context types
index.test-d.ts Rewrites tsd type tests to validate new generic and type guard features
__tests__/typeguards.unit.js New unit tests for all type guard functions
README.md Adds TypeScript documentation, type guard usage examples, and patterns for multi-source handlers

Comment on lines +1 to +665
import { expectType } from 'tsd';

import {
API,
Request,
Response,
CookieOptions,
CorsOptions,
FileOptions,
LoggerOptions,
Options,
Middleware,
ALBContext,
APIGatewayV2Context,
APIGatewayContext,
ErrorHandlingMiddleware,
HandlerFunction,
METHODS,
RouteError,
MethodError,
ConfigurationError,
ResponseError,
FileError,
Middleware,
NextFunction,
RequestContext,
isApiGatewayContext,
isApiGatewayV2Context,
isAlbContext,
isApiGatewayRequest,
isApiGatewayV2Request,
isAlbRequest,
isApiGatewayEvent,
isApiGatewayV2Event,
isAlbEvent,
App,
SourceAgnosticMiddleware,
SourceAgnosticHandler,
SourceAgnosticErrorHandler,
} from './index';
import {
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
Context,
ALBEvent,
Context,
} from 'aws-lambda';

const options: Options = {
base: '/api',
version: 'v1',
logger: {
level: 'info',
access: true,
timestamp: true,
},
compression: true,
interface UserResponse {
id: string;
name: string;
email: string;
}

interface UserQuery extends Record<string, string | undefined> {
fields?: string;
}

interface UserParams extends Record<string, string | undefined> {
id?: string;
}

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

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;
}

const api = new API();

const testContextTypeGuards = () => {
const apiGatewayContext: APIGatewayContext = {} as APIGatewayContext;
const apiGatewayV2Context: APIGatewayV2Context = {} as APIGatewayV2Context;
const albContext: ALBContext = {} as ALBContext;

if (isApiGatewayContext(apiGatewayContext)) {
expectType<APIGatewayContext>(apiGatewayContext);
}

if (isApiGatewayV2Context(apiGatewayV2Context)) {
expectType<APIGatewayV2Context>(apiGatewayV2Context);
}

if (isAlbContext(albContext)) {
expectType<ALBContext>(albContext);
}
};
expectType<Options>(options);

const req = {} as Request;
expectType<string>(req.method);
expectType<string>(req.path);
expectType<{ [key: string]: string | undefined }>(req.params);
expectType<{ [key: string]: string | undefined }>(req.query);
expectType<{ [key: string]: string | undefined }>(req.headers);
expectType<any>(req.body);
expectType<{ [key: string]: string }>(req.cookies);

const apiGwV1Event: APIGatewayProxyEvent = {
body: '{"test":"body"}',
headers: { 'content-type': 'application/json' },
multiValueHeaders: { 'content-type': ['application/json'] },
httpMethod: 'POST',
isBase64Encoded: false,
path: '/test',
pathParameters: { id: '123' },
queryStringParameters: { query: 'test' },
multiValueQueryStringParameters: { query: ['test'] },
stageVariables: { stage: 'dev' },
requestContext: {
accountId: '',
apiId: '',
authorizer: {},
protocol: '',
httpMethod: 'POST',
identity: {
accessKey: null,
accountId: null,
apiKey: null,
apiKeyId: null,
caller: null,
cognitoAuthenticationProvider: null,
cognitoAuthenticationType: null,
cognitoIdentityId: null,
cognitoIdentityPoolId: null,
principalOrgId: null,
sourceIp: '',
user: null,
userAgent: null,
userArn: null,
},
path: '/test',
stage: 'dev',
requestId: '',
requestTimeEpoch: 0,
resourceId: '',
resourcePath: '',
},
resource: '',

const testEventTypeGuards = () => {
const apiGatewayEvent: APIGatewayProxyEvent = {} as APIGatewayProxyEvent;
const apiGatewayV2Event: APIGatewayProxyEventV2 =
{} as APIGatewayProxyEventV2;
const albEvent: ALBEvent = {} as ALBEvent;

if (isApiGatewayEvent(apiGatewayEvent)) {
expectType<APIGatewayProxyEvent>(apiGatewayEvent);
}

if (isApiGatewayV2Event(apiGatewayV2Event)) {
expectType<APIGatewayProxyEventV2>(apiGatewayV2Event);
}

if (isAlbEvent(albEvent)) {
expectType<ALBEvent>(albEvent);
}
};

const apiGwV2Event: APIGatewayProxyEventV2 = {
version: '2.0',
routeKey: 'POST /test',
rawPath: '/test',
rawQueryString: 'query=test',
headers: { 'content-type': 'application/json' },
requestContext: {
accountId: '',
apiId: '',
domainName: '',
domainPrefix: '',
http: {
method: 'POST',
path: '/test',
protocol: 'HTTP/1.1',
sourceIp: '',
userAgent: '',
},
requestId: '',
routeKey: 'POST /test',
stage: 'dev',
time: '',
timeEpoch: 0,
},
body: '{"test":"body"}',
isBase64Encoded: false,
const sourceAgnosticMiddleware: SourceAgnosticMiddleware = (req, res, next) => {
if (isApiGatewayContext(req.requestContext)) {
expectType<string>(req.requestContext.requestId);
const sourceIp = req.requestContext.identity.sourceIp;
if (sourceIp) {
expectType<string>(sourceIp);
}
} else if (isApiGatewayV2Context(req.requestContext)) {
expectType<string>(req.requestContext.requestId);
const sourceIp = req.requestContext.http.sourceIp;
if (sourceIp) {
expectType<string>(sourceIp);
}
} else if (isAlbContext(req.requestContext)) {
expectType<{ targetGroupArn: string }>(req.requestContext.elb);
}
next();
};

const albEvent: ALBEvent = {
requestContext: {
elb: {
targetGroupArn: '',
},
},
httpMethod: 'GET',
path: '/test',
queryStringParameters: {},
headers: {},
body: '',
isBase64Encoded: false,
const albMiddleware: Middleware<
UserResponse,
ALBContext,
UserQuery,
UserParams,
UserBody
> = (req, res, next) => {
expectType<{ targetGroupArn: string }>(req.requestContext.elb);
next();
};

const context: Context = {
callbackWaitsForEmptyEventLoop: true,
functionName: '',
functionVersion: '',
invokedFunctionArn: '',
memoryLimitInMB: '',
awsRequestId: '',
logGroupName: '',
logStreamName: '',
getRemainingTimeInMillis: () => 0,
done: () => {},
fail: () => {},
succeed: () => {},
const apiGwV2Middleware: Middleware<
UserResponse,
APIGatewayV2Context,
UserQuery,
UserParams,
UserBody
> = (req, res, next) => {
expectType<string>(req.requestContext.accountId);
next();
};

const api = new API();
expectType<Promise<any>>(api.run(apiGwV1Event, context));
expectType<Promise<any>>(api.run(apiGwV2Event, context));
// @ts-expect-error ALB events are not supported
expectType<void & Promise<any>>(api.run(albEvent, context));

const res = {} as Response;
expectType<Response>(res.status(200));
expectType<Response>(res.header('Content-Type', 'application/json'));
expectType<Response>(
res.cookie('session', 'value', {
httpOnly: true,
secure: true,
})
const albHandler: HandlerFunction<
UserResponse,
ALBContext,
UserQuery,
UserParams,
UserBody
> = (req, res) => {
expectType<{ targetGroupArn: string }>(req.requestContext.elb);
res.json({
id: '1',
name: req.body.name,
email: req.body.email,
});
};

const apiGwV2Handler: HandlerFunction<
UserResponse,
APIGatewayV2Context,
UserQuery,
UserParams,
UserBody
> = (req, res) => {
expectType<string>(req.requestContext.accountId);
res.json({
id: '1',
name: req.body.name,
email: req.body.email,
});
};

const testRequestTypeGuards = () => {
const req = {} as Request<RequestContext>;

if (isApiGatewayRequest(req)) {
expectType<Request<APIGatewayContext>>(req);
}

if (isApiGatewayV2Request(req)) {
expectType<Request<APIGatewayV2Context>>(req);
}

if (isAlbRequest(req)) {
expectType<Request<ALBContext>>(req);
}
};

const sourceAgnosticHandler: SourceAgnosticHandler<UserResponse> = (
req,
res
) => {
expectType<string>(req.method);
expectType<string>(req.path);
expectType<Record<string, string | undefined>>(req.query);
expectType<Record<string, string | undefined>>(req.headers);
expectType<string>(req.ip);

if (isApiGatewayContext(req.requestContext)) {
expectType<string>(req.requestContext.requestId);
expectType<string>(req.requestContext.identity.sourceIp);
res.json({
id: req.requestContext.requestId,
name: 'API Gateway User',
email: 'user@apigateway.com',
});
} else if (isApiGatewayV2Context(req.requestContext)) {
expectType<string>(req.requestContext.requestId);
expectType<string>(req.requestContext.http.sourceIp);
res.json({
id: req.requestContext.requestId,
name: 'API Gateway V2 User',
email: 'user@apigatewayv2.com',
});
} else if (isAlbContext(req.requestContext)) {
expectType<{ targetGroupArn: string }>(req.requestContext.elb);
res.json({
id: req.requestContext.elb.targetGroupArn,
name: 'ALB User',
email: 'user@alb.com',
});
}
};

api.get<UserResponse>('/source-agnostic', sourceAgnosticHandler);
api.post<UserResponse>(
'/source-agnostic',
sourceAgnosticMiddleware,
sourceAgnosticHandler
);

api.post<UserResponse, RequestContext>(
'/users',
sourceAgnosticMiddleware,
(req: Request<RequestContext>, res: Response<UserResponse>) => {
res.json({
id: '1',
name: 'John',
email: 'john@example.com',
});
}
);

expectType<void>(res.send({ message: 'test' }));
expectType<void>(res.json({ message: 'test' }));
expectType<void>(res.html('<div>test</div>'));
api.post<UserResponse, ALBContext, UserQuery, UserParams, UserBody>(
'/alb-users',
albMiddleware,
albHandler
);

expectType<void>(res.error('Test error'));
expectType<void>(
res.error(500, 'Server error', { details: 'Additional info' })
api.post<UserResponse, APIGatewayV2Context, UserQuery, UserParams, UserBody>(
'/v2-users',
apiGwV2Middleware,
apiGwV2Handler
);

expectType<void>(res.redirect('/new-path'));
const errorHandler: ErrorHandlingMiddleware = (error, req, res, next) => {
if (isAlbContext(req.requestContext)) {
res.status(500).json({
id: 'alb-error',
name: error.name,
email: error.message,
});
} else {
res.status(500).json({
id: 'error',
name: error.name,
email: error.message,
});
}
};

api.use(errorHandler);

api.finally((req, res) => {
if (isApiGatewayContext(req.requestContext)) {
console.log('API Gateway request completed');
} else if (isApiGatewayV2Context(req.requestContext)) {
console.log('API Gateway v2 request completed');
} else if (isAlbContext(req.requestContext)) {
console.log('ALB request completed');
}
});

const result = api.run<UserResponse>({} as APIGatewayProxyEvent, {} as Context);
expectType<Promise<UserResponse>>(result);

api.run<UserResponse>({} as APIGatewayProxyEvent, {} as Context, (err, res) => {
expectType<Error>(err);
expectType<UserResponse>(res);
});

const testHttpMethods = () => {
api.get<UserResponse, APIGatewayContext, UserQuery, UserParams>(
'/users/:id',
(
req: Request<APIGatewayContext, UserQuery, UserParams>,
res: Response<UserResponse>
) => {
expectType<string | undefined>(req.params.id);
res.json({ id: '1', name: 'John', email: 'test@example.com' });
}
);

api.put<UserResponse, APIGatewayContext, UserQuery, UserParams, UserBody>(
'/users/:id',
(
req: Request<APIGatewayContext, UserQuery, UserParams, UserBody>,
res: Response<UserResponse>
) => {
expectType<UserBody>(req.body);
res
.status(200)
.json({ id: '1', name: req.body.name, email: req.body.email });
}
);

api.patch<
UserResponse,
APIGatewayContext,
UserQuery,
UserParams,
Partial<UserBody>
>(
'/users/:id',
(
req: Request<APIGatewayContext, UserQuery, UserParams, Partial<UserBody>>,
res: Response<UserResponse>
) => {
expectType<Partial<UserBody>>(req.body);
res.json({ id: '1', name: 'John', email: 'test@example.com' });
}
);

api.delete<void, APIGatewayContext>(
'/users/:id',
(req: Request<APIGatewayContext>, res: Response<void>) => {
res.status(204).send();
}
);

api.head<void, APIGatewayContext>(
'/users',
(req: Request<APIGatewayContext>, res: Response<void>) => {
res.status(200).send();
}
);

const middleware: Middleware = (req, res, next) => {
api.options<void, APIGatewayContext>(
'/users',
(req: Request<APIGatewayContext>, res: Response<void>) => {
res.header('Allow', 'GET, POST, PUT, DELETE').status(204).send();
}
);

api.any<{ method: string }, APIGatewayContext>(
'/wildcard',
(req: Request<APIGatewayContext>, res: Response<{ method: string }>) => {
expectType<string>(req.method);
res.send({ method: req.method });
}
);
};

const pathSpecificMiddleware: Middleware<any, RequestContext> = (
req,
res,
next
) => {
req.log.info('Path-specific middleware');
next();
};
expectType<Middleware>(middleware);

const errorMiddleware: ErrorHandlingMiddleware = (error, req, res, next) => {
res.status(500).json({ error: error.message });
api.use('/specific-path', pathSpecificMiddleware);
api.use(['/path1', '/path2'], pathSpecificMiddleware);

interface RequestWithCustom1 extends Request<RequestContext> {
custom1: string;
}

interface RequestWithCustom2 extends Request<RequestContext> {
custom2: string;
}

const middleware1: Middleware<any, RequestContext> = (req, res, next) => {
(req as RequestWithCustom1).custom1 = 'value1';
next();
};
expectType<ErrorHandlingMiddleware>(errorMiddleware);

const handler: HandlerFunction = (req, res) => {
res.json({ success: true });
const middleware2: Middleware<any, RequestContext> = (req, res, next) => {
(req as RequestWithCustom2).custom2 = 'value2';
next();
};
expectType<HandlerFunction>(handler);

const cookieOptions: CookieOptions = {
domain: 'example.com',
httpOnly: true,
secure: true,
sameSite: 'Strict',
api.use(middleware1, middleware2);

const handlerWithCustomProps: HandlerFunction<any, RequestContext> = (
req,
res
) => {
if ('custom1' in req) {
expectType<string>((req as RequestWithCustom1).custom1);
}
if ('custom2' in req) {
expectType<string>((req as RequestWithCustom2).custom2);
}
res.send({ status: 'ok' });
};
expectType<CookieOptions>(cookieOptions);

const corsOptions: CorsOptions = {
origin: '*',
methods: 'GET,POST',
headers: 'Content-Type,Authorization',
credentials: true,
const testRequestProperties: HandlerFunction<any, RequestContext> = (
req,
res
) => {
expectType<string>(req.id);
expectType<string>(req.method);
expectType<string>(req.path);
expectType<Record<string, string | undefined>>(req.query);
expectType<Record<string, string | undefined>>(req.headers);
expectType<string>(req.ip);
expectType<string>(req.userAgent);
expectType<'desktop' | 'mobile' | 'tv' | 'tablet' | 'unknown'>(
req.clientType
);
expectType<string>(req.clientCountry);
expectType<boolean>(req.coldStart);
expectType<number>(req.requestCount);
expectType<'apigateway' | 'alb'>(req.interface);
expectType<string | undefined>(req.payloadVersion);
expectType<App>(req.ns);
req.log.trace('trace message');
req.log.debug('debug message');
req.log.info('info message');
req.log.warn('warn message');
req.log.error('error message');
req.log.fatal('fatal message');
};
expectType<CorsOptions>(corsOptions);

const fileOptions: FileOptions = {
maxAge: 3600,
root: '/public',
lastModified: true,
headers: { 'Cache-Control': 'public' },
const testResponseMethods: HandlerFunction<any, RequestContext> = (
req,
res
) => {
res
.status(201)
.header('X-Custom', 'value')
.type('json')
.cors({
origin: '*',
methods: 'GET, POST',
headers: 'Content-Type',
})
.cookie('session', 'value', { httpOnly: true })
.cache(3600)
.etag(true)
.modified(new Date());

expectType<string>(res.getHeader('X-Custom'));
expectType<{ [key: string]: string }>(res.getHeaders());
expectType<boolean>(res.hasHeader('X-Custom'));
res.removeHeader('X-Custom');

res.send({ data: 'raw' });
res.json({ data: 'json' });
res.jsonp({ data: 'jsonp' });
res.html('<div>html</div>');
res.sendStatus(204);

res.redirect('/new-location');
res.redirect(301, '/permanent-location');

res.clearCookie('session');

res.error(400, 'Bad Request');
res.error('Error message');
};
expectType<FileOptions>(fileOptions);

const loggerOptions: LoggerOptions = {
level: 'info',
access: true,
timestamp: true,
sampling: {
target: 10,
rate: 0.1,
},

const testErrorHandlingMiddleware: ErrorHandlingMiddleware = async (
error,
req,
res,
next
) => {
if (error.message === 'sync') {
return { message: 'handled synchronously' };
}

if (error.message === 'async') {
return Promise.resolve({ message: 'handled asynchronously' });
}

if (error.message === 'void') {
res.json({ message: 'handled with void' });
return;
}

if (error.message === 'promise-void') {
await Promise.resolve();
res.json({ message: 'handled with promise void' });
return;
}

next();
};
expectType<LoggerOptions>(loggerOptions);

const methods: METHODS[] = [
'GET',
'POST',
'PUT',
'DELETE',
'OPTIONS',
'HEAD',
'ANY',
];
expectType<METHODS[]>(methods);

const routeError = new RouteError('Route not found', '/api/test');
expectType<RouteError>(routeError);

const methodError = new MethodError(
'Method not allowed',
'POST' as METHODS,
'/api/test'
);
expectType<MethodError>(methodError);

const configError = new ConfigurationError('Invalid configuration');
expectType<ConfigurationError>(configError);
const testDefaultTypes = () => {
api.get('/simple', (req: Request<APIGatewayContext>, res: Response) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
expectType<APIGatewayContext>(req.requestContext);
expectType<Record<string, string | undefined>>(req.query);
expectType<Record<string, string | undefined>>(req.params);
expectType<any>(req.body);
res.json({ message: 'ok' });
});

const responseError = new ResponseError('Response error', 500);
expectType<ResponseError>(responseError);
const simpleMiddleware: Middleware = (
req: Request<APIGatewayContext>,
res: Response,
next: NextFunction
) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
expectType<APIGatewayContext>(req.requestContext);
next();
};

const fileError = new FileError('File not found', {
code: 'ENOENT',
syscall: 'open',
});
expectType<FileError>(fileError);
const simpleErrorHandler: ErrorHandlingMiddleware = (
error: Error,
req: Request<APIGatewayContext>,
res: Response,
next: NextFunction
) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
expectType<APIGatewayContext>(req.requestContext);
res.status(500).json({ error: error.message });
};

api.post(
'/simple-chain',
simpleMiddleware,
(req: Request<APIGatewayContext>, res: Response) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
res.json({ status: 'ok' });
}
);

api.use(
(req: Request<APIGatewayContext>, res: Response, next: NextFunction) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
next();
}
);

api.use(
'/path',
(req: Request<APIGatewayContext>, res: Response, next: NextFunction) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
next();
}
);

api.finally((req: Request<APIGatewayContext>, res: Response) => {
expectType<Request<APIGatewayContext>>(req);
expectType<Response>(res);
});

const runResult = api.run({} as APIGatewayProxyEvent, {} as Context);
expectType<Promise<any>>(runResult);

api.run({} as APIGatewayProxyEvent, {} as Context, (err: Error, res: any) => {
expectType<Error>(err);
expectType<any>(res);
});

const albApi = new API();
albApi.get('/alb-default', (req: Request<ALBContext>, res: Response) => {
if (isAlbContext(req.requestContext)) {
expectType<ALBContext>(req.requestContext);
expectType<{ targetGroupArn: string }>(req.requestContext.elb);
expectType<Record<string, string | undefined>>(req.query);
expectType<Record<string, string | undefined>>(req.params);
expectType<any>(req.body);
res.json({ message: 'ALB response' });
}
});

const albResult = albApi.run({} as ALBEvent, {} as Context);
expectType<Promise<any>>(albResult);

const apiGwV2Api = new API();
apiGwV2Api.get(
'/apigw-v2-default',
(req: Request<APIGatewayV2Context>, res: Response) => {
if (isApiGatewayV2Context(req.requestContext)) {
expectType<APIGatewayV2Context>(req.requestContext);
expectType<string>(req.requestContext.accountId);
expectType<Record<string, string | undefined>>(req.query);
expectType<Record<string, string | undefined>>(req.params);
expectType<any>(req.body);
res.json({ message: 'API Gateway V2 response' });
}
}
);

const apiGwV2Result = apiGwV2Api.run(
{} as APIGatewayProxyEventV2,
{} as Context
);
expectType<Promise<any>>(apiGwV2Result);
};

const testSourceAgnosticTypes = () => {
// Test source-agnostic handler with minimal type parameters
const simpleSourceAgnosticHandler: SourceAgnosticHandler = (req, res) => {
expectType<RequestContext>(req.requestContext);
res.json({ message: 'ok' });
};

// Test source-agnostic handler with response type
const typedSourceAgnosticHandler: SourceAgnosticHandler<UserResponse> = (
req,
res
) => {
res.json({
id: '1',
name: 'John',
email: 'john@example.com',
});
};

// Test source-agnostic middleware with minimal type parameters
const simpleSourceAgnosticMiddleware: SourceAgnosticMiddleware = (
req,
res,
next
) => {
expectType<RequestContext>(req.requestContext);
next();
};

// Test source-agnostic error handler
const sourceAgnosticErrorHandler: SourceAgnosticErrorHandler = (
error,
req,
res,
next
) => {
expectType<RequestContext>(req.requestContext);
res.status(500).json({ error: error.message });
};

// Test using source-agnostic types with API
api.get('/source-agnostic', simpleSourceAgnosticHandler);
api.post(
'/source-agnostic',
simpleSourceAgnosticMiddleware,
typedSourceAgnosticHandler
);
api.use(sourceAgnosticErrorHandler);
};
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated index.test-d.ts removes type-level test coverage for several previously-tested exports: CookieOptions, CorsOptions, FileOptions, Options, METHODS, RouteError, MethodError, ConfigurationError, ResponseError, and FileError. These types still exist in index.d.ts but are no longer validated by any type test. Since this project uses tsd for type testing, these exports should remain covered to prevent future regressions.

Copilot uses AI. Check for mistakes.
Request,
Response,
SourceAgnosticHandler,
SourceAgnosticMiddleware,
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TypeScript Support code example in the README uses APIGatewayContext at line 103, but this type is not included in the import statement shown at lines 42-50. Users copying this example would get a TypeScript error. The import block should include APIGatewayContext (and potentially ALBContext for completeness).

Suggested change
SourceAgnosticMiddleware,
SourceAgnosticMiddleware,
APIGatewayContext,
ALBContext,

Copilot uses AI. Check for mistakes.
### Type Safety Examples

Contributions, ideas and bug reports are welcome and greatly appreciated. Please add [issues](https://github.com/jeremydaly/lambda-api/issues) for suggestions and bug reports or create a pull request.
```typescript
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Type Safety Examples" code snippet uses UserType at line 1748, but this type is never defined in the code snippet or the surrounding context. This would cause a TypeScript compile error for anyone copying this example. It should be replaced with an actual type definition (e.g., defining interface UserType { id: string; roles: string[]; email: string; } above or using an existing type like UserResponse).

Suggested change
```typescript
```typescript
interface UserType {
id: string;
roles: string[];
email: string;
}

Copilot uses AI. Check for mistakes.
Comment on lines +1800 to +1804
api.get<Response, APIGatewayRequestContext>('/api-gateway', (req, res) => {
console.log(req.requestContext.identity);
});

api.get<Response, ALBRequestContext>('/alb', (req, res) => {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README's second "Handling Multiple Request Sources" section (near the end of the file) uses the non-existent type names APIGatewayRequestContext and ALBRequestContext as type parameters. The correct exported types are APIGatewayContext and ALBContext respectively. Additionally, Response is used as the first type parameter (the response body type), but Response is the response class, not a response body shape. The earlier section of the same README (around line 173) correctly uses APIGatewayContext and ALBContext, making this a clear inconsistency in the documentation.

Suggested change
api.get<Response, APIGatewayRequestContext>('/api-gateway', (req, res) => {
console.log(req.requestContext.identity);
});
api.get<Response, ALBRequestContext>('/alb', (req, res) => {
api.get<{ message: string }, APIGatewayContext>('/api-gateway', (req, res) => {
console.log(req.requestContext.identity);
});
api.get<{ message: string }, ALBContext>('/alb', (req, res) => {

Copilot uses AI. Check for mistakes.
Comment on lines +1791 to +1815
## Handling Multiple Request Sources

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

api.get<Response, APIGatewayRequestContext>('/api-gateway', (req, res) => {
console.log(req.requestContext.identity);
});

api.get<Response, ALBRequestContext>('/alb', (req, res) => {
console.log(req.requestContext.elb);
});

api.get('/any', (req, res) => {
if (isApiGatewayContext(req.requestContext)) {
console.log(req.requestContext.identity);
} else if (isAlbContext(req.requestContext)) {
console.log(req.requestContext.elb);
}
});
```
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The section heading "## Handling Multiple Request Sources" appears twice in the README — at line 160 and line 1791. This creates a duplicate heading with conflicting content, where the TOC entry at line 295 can only link to one of them. The second occurrence (line 1791) only contains a short code snippet and duplicates content already present in the first. The duplicate section should be merged or removed to avoid confusion.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +101
interface UserQuery {
fields: string;
}
interface UserParams {
id: string;
}
interface UserBody {
name: string;
email: string;
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UserQuery and UserParams interfaces defined in the README TypeScript example (lines 92-101) are not compatible with the generic type constraint TQuery extends Record<string, string | undefined>. The interfaces declare non-optional string properties (fields: string, id: string), but the constraint requires values to be string | undefined. Using these definitions at line 103 would cause a TypeScript compile error. The properties should be declared as optional (e.g., fields?: string) or the interfaces should extend Record<string, string | undefined>, consistent with how they are defined in index.test-d.ts.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in 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

4 participants