Skip to content

fix(event-handler): http response body validation typings - #5125

Merged
svozza merged 9 commits into
aws-powertools:mainfrom
nateiler:http-validation-types
Mar 23, 2026
Merged

fix(event-handler): http response body validation typings#5125
svozza merged 9 commits into
aws-powertools:mainfrom
nateiler:http-validation-types

Conversation

@nateiler

Copy link
Copy Markdown
Contributor

Summary

The new http validation res body throws some type error when a schema's input and output are not the same. It also throws an error when returning a Response object.

Changes

Updated TypedRouteHandler types to include a Response object and the input of the res body schema.


On another note, it would be interesting to explore an option where the validated response could automatically update/replace the returned response.

const responseSchema = z.object({ id: z.coerce.string(), name: z.string() });

That schema technically validates { id: 123, name: 'John' }, but the result is not { id: "123", name: 'John' } as the response schema coerced it.


Issue number: closes #5124


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Disclaimer: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful.

@pull-request-size pull-request-size Bot added the size/M PR between 30-99 LOC label Mar 21, 2026
@svozza

svozza commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

Thanks for the PR! The types in this part of the code are quite complex and I think we need to be quite careful here. I got Claude to generate some type level tests (which I should have added in my original PR) and I think I've found an issue.

  describe('Response validation typing', () => {                                                                        
    it('infers the output type for a standard response schema', () => {                                                 
      const responseSchema = z.object({ id: z.string(), name: z.string() });                                            
      type Config = { res: { body: typeof responseSchema } };                                                           
                                                                                                                        
      expectTypeOf<InferResBody<Config>>().toEqualTypeOf<{                                                              
        id: string;                                                                                                     
        name: string;                                                                                                   
      }>();                                                                                                             
    });                                                                                                                 
    
    // passes on main, fails on your branch                                                                                         
    it('infers the output type for a coerced response schema', () => {                                                  
      const responseSchema = z.object({                                                                                 
        id: z.coerce.string(),                                                                                          
        name: z.string(),                                                                                               
      });                                                                                                               
      type Config = { res: { body: typeof responseSchema } };                                                           
                                                                                                                        
      expectTypeOf<InferResBody<Config>>().toEqualTypeOf<{                                                              
        id: string;                                                                                                     
        name: string;                                                                                                   
      }>();       
    });                                                                                                                 
                  
    it('rejects handler returning wrong types with a standard schema', () => {                                          
      const app = new Router();
      const responseSchema = z.object({ id: z.string(), name: z.string() });                                            
                                                                                                                        
      app.get(                                                                                                          
        '/users/:id',                                                                                                   
        // @ts-expect-error - number is not assignable to string for id                                                 
        () => {                                                                                                         
          return { id: 123, name: 'John' };                                                                             
        },                                                                                                              
        { validation: { res: { body: responseSchema } } }                                                               
      );                                                                                                                
    });                                                                                                                 
         
    // fails on main, passes on your branch                                                                                                       
    it('accepts handler returning pre-coercion types with z.coerce', () => {                                            
      const app = new Router();
      const responseSchema = z.object({                                                                                 
        id: z.coerce.string(),
        name: z.string(),
      });                                                                                                               
   
      app.get(                                                                                                          
        '/users/:id',
        () => {                                                                                                         
          return { id: 123, name: 'John' };
        },                                                                                                              
        { validation: { res: { body: responseSchema } } }                                                               
      );                                                                                                                
    });                                                                                                                 
    
    // fails on main, passes on your branch                                                                                                                    
    it('accepts handler returning a Response object with response validation', () => {                                  
      const app = new Router();
      const responseSchema = z.object({ id: z.string(), name: z.string() });                                            
                                                                                                                        
      app.get(                                                                                                          
        '/users/:id',                                                                                                   
        () => {                                                                                                         
          return Response.json({ id: '123', name: 'John' });                                                            
        },                                                                                                              
        { validation: { res: { body: responseSchema } } }                                                               
      );                                                                                                                
    });                                                                                                                 
                                                                                                                        
    it('infers validated response body as output type for a standard schema', () => {                                   
      const responseSchema = z.object({ id: z.string(), name: z.string() });
      type Config = { res: { body: typeof responseSchema } };                                                           
                                                                                                                        
      expectTypeOf<InferResSchema<Config>>().toEqualTypeOf<{                                                            
        body: { id: string; name: string };                                                                             
        headers: undefined;                                                                                             
      }>();       
    });                                                                                                                 
                  
    it('infers validated response body as output type for a coerced schema', () => {                                    
      const responseSchema = z.object({
        id: z.coerce.string(),                                                                                          
        name: z.string(),                                                                                               
      });                                                                                                               
      type Config = { res: { body: typeof responseSchema } };                                                           
                                                                                                                        
      expectTypeOf<InferResSchema<Config>>().toEqualTypeOf<{                                                            
        body: { id: string; name: string };                                                                             
        headers: undefined;                                                                                             
      }>();                                                                                                             
    });                                                                                                                 
  });                                                                                                                   

Here's what I think is happening: InferResBody's contract is to return the output type of the response body schema. The PR changes it to return the input type instead, which fixes the handler return type but has a side effect: for coerce schemas, InferResBody now returns { id: unknown; name: string } instead of { id: string; name: string }.

This doesn't currently break InferResSchema (which has its own independent inference using the output type), but it does change the semantics of InferResBody for any external consumer.

What we could do instead though is rather than changing InferResBody, introduce a separate InferResBodyInput type and use that for the handler return type in the router overloads:

type InferResBodyInput<V extends ValidationConfig> = V extends {
  res: { body: infer S extends StandardSchemaV1 };                                                                    
}                                                                                                                     
  ? StandardSchemaV1.InferInput<S>                                                                                    
  : HandlerResponse;                                                                                                  

Then in the router overloads, use InferResBodyInput<V> for the handler's TResBody parameter while keeping
InferResBody<V> (output type) unchanged.

This way the handler return type uses the input type (accepts pre-coercion values) and InferResBody / InferResSchema / valid.res.body remain the output type (post-validation)

@pull-request-size pull-request-size Bot added size/L PRs between 100-499 LOC and removed size/M PR between 30-99 LOC labels Mar 22, 2026
@nateiler

Copy link
Copy Markdown
Contributor Author

Isn't the input typing of { id: unknown; name: string } correct because

z.object({
  id: z.coerce.string(),                                                                                          
  name: z.string(),                                                                                               
})

results in that from the inferred input?

Where as:

z.object({
  id: z.number(),
  name: z.string(),
})
.transform((r) => ({
  ...r,
  id: String(r.id),
}));

has an input of { id: number; name: string } and an output of { id: string; name: string }.

I added the type checks you proposed, but they didn't seem to highlight an issue; and i was having a hard time following the type complexity that you were explaining.

@nateiler

Copy link
Copy Markdown
Contributor Author

Additionally, is this expected to fail? A coerced response schema isn't the actual result body.

it('validates a coerced response successfully', async () => {
    // Prepare
    const responseSchema = z.object({
      id: z.coerce.string(),
      name: z.string(),
    });

    app.get(
      '/users/:id',
      () => {
        return { id: 123, name: 'John' };
      },
      {
        validation: { res: { body: responseSchema } },
      }
    );

    const event = createTestEvent('/users/123', 'GET', {});
    event.pathParameters = { id: '123' };

    // Act
    const result = await app.resolve(event, context);

    // Assess
    expect(result.statusCode).toBe(200);
    expect(result.body).toEqual('{"id":"123","name":"John"}');
  });

@svozza

svozza commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Isn't the input typing of { id: unknown; name: string } correct because

z.object({
  id: z.coerce.string(),                                                                                          
  name: z.string(),                                                                                               
})

results in that from the inferred input?

Where as:

z.object({
  id: z.number(),
  name: z.string(),
})
.transform((r) => ({
  ...r,
  id: String(r.id),
}));

has an input of { id: number; name: string } and an output of { id: string; name: string }.

I added the type checks you proposed, but they didn't seem to highlight an issue; and i was having a hard time following the type complexity that you were explaining.

Oh actually you're right! I got confused here with the coercion. Good to have these tests though, as I said, I should have added them originally.

Comment thread packages/event-handler/tests/types/http.test-d.ts Outdated
@nateiler

Copy link
Copy Markdown
Contributor Author

Additionally, is this expected to fail? A coerced response schema isn't the actual result body.

it('validates a coerced response successfully', async () => {
    // Prepare
    const responseSchema = z.object({
      id: z.coerce.string(),
      name: z.string(),
    });

    app.get(
      '/users/:id',
      () => {
        return { id: 123, name: 'John' };
      },
      {
        validation: { res: { body: responseSchema } },
      }
    );

    const event = createTestEvent('/users/123', 'GET', {});
    event.pathParameters = { id: '123' };

    // Act
    const result = await app.resolve(event, context);

    // Assess
    expect(result.statusCode).toBe(200);
    expect(result.body).toEqual('{"id":"123","name":"John"}');
  });

@svozza This isn't directly related to this issue; would you like to move to a separate discussion?

@svozza

svozza commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Additionally, is this expected to fail? A coerced response schema isn't the actual result body.

it('validates a coerced response successfully', async () => {
    // Prepare
    const responseSchema = z.object({
      id: z.coerce.string(),
      name: z.string(),
    });

    app.get(
      '/users/:id',
      () => {
        return { id: 123, name: 'John' };
      },
      {
        validation: { res: { body: responseSchema } },
      }
    );

    const event = createTestEvent('/users/123', 'GET', {});
    event.pathParameters = { id: '123' };

    // Act
    const result = await app.resolve(event, context);

    // Assess
    expect(result.statusCode).toBe(200);
    expect(result.body).toEqual('{"id":"123","name":"John"}');
  });

@svozza This isn't directly related to this issue; would you like to move to a separate discussion?

Yes, makes sense.

svozza
svozza previously approved these changes Mar 23, 2026
Comment thread packages/event-handler/tests/types/http.test-d.ts Outdated
@svozza
svozza dismissed their stale review March 23, 2026 14:57

Missed comment that needs removing

@sonarqubecloud

Copy link
Copy Markdown

@svozza

svozza commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

LGTM. Approved!

@svozza
svozza merged commit 0805db9 into aws-powertools:main Mar 23, 2026
46 checks passed
@rcaughtlaf

Copy link
Copy Markdown

@nateiler I think something similar is happening around the req path and headers if coercion attempted

@dreamorosi

Copy link
Copy Markdown
Contributor

Hi @rcaughtlaf - if possible, please open a dedicated issue and reference this PR or the linked issue.

Thanks!

@nateiler

nateiler commented Apr 6, 2026

Copy link
Copy Markdown
Contributor Author

@nateiler I think something similar is happening around the req path and headers if coercion attempted

There is this to follow along: #5133

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L PRs between 100-499 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Event handler response validation typing issues

4 participants