4

Very similar to Using partial shape for unit testing with typescript but I'm failing to understand why the Partial type is seen as being incompatible with the full version.

I have a unit test which check if a lambda returns 400 if the body in an AWS lambda event isn't valid. To avoid creating noise for my colleagues, I don't want to create invalidEvent with all the properties of a full APIGatewayProxyEvent. Hence using a Partial<APIGatewayProxyEvent>.

  it("should return 400 when request event is invalid", async () => {
    const invalidEvent: Partial<APIGatewayProxyEvent> = {
      body: JSON.stringify({ foo: "bar" }),
    };
    const { statusCode } = await handler(invalidEvent);
    expect(statusCode).toBe(400);
  });

The const { statusCode } = await handler(invalidEvent); line fails compilation with:

Argument of type 'Partial<APIGatewayProxyEvent>' is not assignable to parameter of type 'APIGatewayProxyEvent'.
  Types of property 'body' are incompatible.
    Type 'string | null | undefined' is not assignable to type 'string | null'.
      Type 'undefined' is not assignable to type 'string | null'.ts(2345)

I understand APIGatewayProxyEvent body can be string | null (from looking at the types) but where did string | null | undefined come from? Why isn't my body - which is a string - a valid body for APIGatewayProxyEvent

How can I use TypeScript Partials to test AWS Lambda?

I could use as to do type assertions but I find Partials more explicit. The following code works though:

    const invalidEvent = { body: JSON.stringify({ foo: "bar" }) } as APIGatewayProxyEvent;

Update: using Omit and Pick to make a new type

  type TestingEventWithBody = Omit<Partial<APIGatewayProxyEvent>, "body"> & Pick<APIGatewayProxyEvent, "body">;

  it("should return 400 when request event is invalid", async () => {
    const invalidEvent: TestingEventWithBody = { body: JSON.stringify({ foo: "bar" }) };
    const { statusCode } = await handler(invalidEvent);
    expect(statusCode).toBe(400);
  });

Fails with:

Argument of type 'TestingEventWithBody' is not assignable to parameter of type 'APIGatewayProxyEvent'.
  Types of property 'headers' are incompatible.
    Type 'APIGatewayProxyEventHeaders | undefined' is not assignable to type 'APIGatewayProxyEventHeaders'.
      Type 'undefined' is not assignable to type 'APIGatewayProxyEventHeaders'.ts(2345)
mikemaccana
  • 110,530
  • 99
  • 389
  • 494
  • `string | null | undefined` came from taking `string | null` and adding `undefined`, which is what `Partial` does to make every property optional. The body is a string, but that's irrelevant - it's being accessed through an interface that says it might not be. – jonrsharpe Oct 29 '21 at 13:15
  • @jonrsharpe Sure but why is using a string as a key that's allowed to be a string not allowed? – mikemaccana Oct 29 '21 at 13:52
  • It _is_ allowed, the assignment to `invalidEvent` is just fine. – jonrsharpe Oct 29 '21 at 13:54
  • 1
    And that error is **not** coming from the assignment to `invalidEvent`, it's coming when you try to pass it to `handler` which wants a `APIGatewayProxyEvent` not a `Partial` (because the latter may be missing any or all of the properties `handler` needs). – jonrsharpe Oct 29 '21 at 13:57
  • _(Note this has absolutely nothing to to with AWS, Jest or ts-jest, or even really partials, and it's just an alphabetical coincidence that the property it tells you about is the only one you've supplied: https://tsplay.dev/NrvL2N)_ – jonrsharpe Oct 29 '21 at 14:12
  • I was responding to your comment _"...why is using a string as a key that's allowed to be a string not allowed?"_ That's _not_ what's happening on the line that's erroring. You're using a string as a key that's allowed to be a string in the _assignment_, which is fine. – jonrsharpe Oct 29 '21 at 14:42
  • 1
    When you're calling the function, you're **not** using a string - that's the problem. That's exactly what the error message tells you. You're using a `Partial` whose `body` is `string | null | undefined`. _"I'm failing to understand why the Partial type is seen as being incompatible with the full version"_ - because by definition (assuming you're not using it redundantly on something where all the props were already optional) that's what `Partial` does, it makes the properties that were required no longer required. – jonrsharpe Oct 29 '21 at 14:45
  • Yeah I think this is starting to sink in - that I can define a type that's a partial, and that's fine, and that when defining that value it can only have body, and that's also fine. **But none of this changes that the handler expects a full implementation.** A possible solution could be - if the handler genuinely doesn't require all the properties - to make the handler use the Partial / Omitted type. Thanks for your patience too. – mikemaccana Oct 29 '21 at 14:48
  • 1
    Yes, that would be an application of the _interface segregation principle_, limiting `handler` to only the properties it actually needs to do its job. An `APIGatewayProxyEvent` is compatible with a `Partial` or something with a subset or properties via `Pick` or `Omit`, just not the other way around. – jonrsharpe Oct 29 '21 at 14:50
  • @jonrsharpe Much appreciated. Do you want to add an answer like that, ie limiting handler to only the properties it actually needs to do its job? Eg for some projects, my handlers won't care about event properties like `stageVariables`, `isBase64Encoded` etc. For others - where the handler does need all properties, it seems like `as` is the right approach to specifically tell the TS compiler that an object missing required keys is OK. – mikemaccana Oct 29 '21 at 14:55
  • Also @jonrsharpe I misinterpreted you earlier - I apologize for that. Your assistance (and particularly the comment on the linked question's answer re: the answer actually using `as`) has been essential to me understand what's going on here. – mikemaccana Oct 29 '21 at 15:00

2 Answers2

4

I'm failing to understand why the Partial type is seen as being incompatible with the full version

Fundamentally, that's inevitable - you started with something that required the body property to be string | null, and created something with the weaker requirement string | null | undefined. You did provide the body in this case, but that doesn't matter because handler is only seeing invalidEvent through the Partial<APIGatewayProxyEvent> interface and the compiler knows that property could be missing. As you've seen, if you patch up that one property to be required again it just complains about the next one instead.

In cases where you don't own the handler API, you only really have three choices, none of which is ideal:

  1. Actually provide a full APIGatewayProxyEvent (see the end for a shortcut to this);
  2. Claim to the compiler that your test object is a full APIGatewayProxyEvent with a type assertion; or
  3. Tell the compiler not to check it at all with a // @ts-ignore comment.

Using Partial is generally just a step in option 2, using:

const thing: Partial<Thing> = { ... };
whatever(thing as Thing);

instead of:

const thing = { ... } as Thing;
whatever(thing);

If you own handler's API, the best way to do this is to apply the interface segregation principle and be specific about what it actually needs to do its job. If it's only the body, for example:

type HandlerEvent = Pick<APIGatewayProxyEvent, "body">;

function handler(event: HandlerEvent) { ... } 

A full APIGatewayProxyEvent is still a valid argument to handler, because that definitely does have a body (and the fact that it also has other properties is irrelevant, they're inaccessible via HandlerEvent). This also acts as built-in documentation as to what you're actually consuming from the full object.

In your tests, you can now just create the smaller object:

it("should return 400 when request event is invalid", async () => {
  const invalidEvent: HandlerEvent = { body: JSON.stringify({ foo: "bar" }) };
  const { statusCode } = await handler(invalidEvent);
  expect(statusCode).toBe(400);
});

As a bonus, if it turns out later on that you need to access more of event's properties inside handler, you can update the type:

type HandlerEvent = Pick<APIGatewayProxyEvent, "body" | "headers">;

and you'll get errors everywhere you need to update the test data to take account of that. This would not happen with const invalidEvent = { ... } as APIGatewayProxyEvent;, you'd have to track down the changes by seeing which tests failed at runtime.


Another shortcut I've seen used with option 1 is to wrap a function around the partial, providing sensible defaults:

function createTestData(overrides: Partial<APIGatewayProxyEvent>): APIGatewayProxyEvent {
  return {
    body: null,
    headers: {},
    // etc.
    ...overrides,
  };
}

it("should return 400 when request event is invalid", async () => {
  const invalidEvent = createTestData({ body: JSON.stringify({ foo: "bar" }) });
  const { statusCode } = await handler(invalidEvent);
  expect(statusCode).toBe(400);
});

In this case you should make the defaults as minimal as possible (null, 0, "", empty objects and arrays, ...), to avoid any particular behaviour depending on them.

jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
0

The problem here is the partial type converts all object properties to optional:

type MyObj = { myKey: string | null };

type MyPartialObj = Partial<MyObj>;
// MyPartialObj is: { myKey?: string | null | undefined }

In the type MyObj the myKey type was string | null. When we converted it to MyPartialObj the myKey type became optional and thus has the potential to be undefined. So now its type is string | null | undefined

Your APIGatewayProxyEvent type expects body to be string | null, however since you've made it partial you are saying body could also be undefined. Yes you have defined it, but you never did a type narrowing to validate that it is indeed a string. So all TypeScript has to go off of is the type you assigned, which again is Partial.


UPDATE: Extending on what @jonrsharpe has said in the comments. My previous solution seems to not work ether, it just pushed the error on to the next property in APIGatewayProxyEvent. See their answer. The issue is that you're trying to mock a part of the data and the type expects all data to be present. It might just be easiest to make an object that has the minimal values for each property instead of trying to fake it. You could use as but that defeats the whole point of using the type system in the first place.


Previous answer: A solution might be to make all values optional except body:

const invalidEvent: Omit<Partial<APIGatewayProxyEvent>, 'body'> & Pick<APIGatewayProxyEvent, 'body'> = {
    body: JSON.stringify({ foo: "bar" }),
};

Also, avoid using as x like the plague or unless you're absolutely sure what you're doing.

zbauman
  • 217
  • 2
  • 8
  • Thanks Z! I understand the first part of your answer (about Partial making all properties optional) but I'm having trouble understanding "Yes you have defined it, but you never did a type narrowing to validate that it is indeed a string. So all TypeScript has to go off of is the type you assigned, which again is Partial.". OK, so body is now undefined or sting or null, why doesn't specifying a body (which fits 'string') work? – mikemaccana Oct 29 '21 at 13:51
  • 1
    @mikemaccana because that doesn't change the type of `invalidEvent`. Just because you _did_ assign a value to that property, it doesn't change the fact that you weren't _required_ to and might not have. `handler` only knows it's getting a `Partial`. – jonrsharpe Oct 29 '21 at 13:58
  • @mikemaccana Because all TS has to go off of is the type you assigned to the variable `invalidEvent` which is `Partial` and the type that the `handler` function expects which is `APIGatewayProxyEvent`. Those don't match. From TS points of view all it knows is the type assigned to the variable. – zbauman Oct 29 '21 at 13:58
  • @jonrsharpe But if a `Partial` allows one to include a `body` as a `string` then I don't understand why it is invalid to include a `body` as a `string`. – mikemaccana Oct 29 '21 at 14:00
  • Z: minor nitpick but I think you might be picking and omitting the same property above. – mikemaccana Oct 29 '21 at 14:00
  • 1
    @mikemaccana again, it's **not** invalid to include a `body` as a `string` to a `Partial`, which is why your assignment to `invalidEvent` is absolutely fine - it can be a `string`, `null` or (due to the `Partial`) `undefined`/missing entirely. – jonrsharpe Oct 29 '21 at 14:01
  • 1
    @mikemaccana yes I am picking and omitting the same property, intentionally. The `Omit` is omitting it from the `Partial`. The `Pick` is picking it from the original, required type. Thus the end result is `body` is required while all others are optional. – zbauman Oct 29 '21 at 14:04
  • With this answer, unless `body` is the only thing an `APIGatewayProxyEvent` is required to have (in which case I doubt the OP would be jumping through these hoops at all) you'll just move the error to the _next_ missing property. – jonrsharpe Oct 29 '21 at 14:05
  • @jonrsharpe agreed, but I can't solve all of their problems – zbauman Oct 29 '21 at 14:06
  • I don't know if anyone would consider _"you get an error on [`headers`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/4c91e2736e1b4e87485ecacd4a6fabe10b8af504/types/aws-lambda/trigger/api-gateway-proxy.d.ts#L89-L102) instead"_ to be a solution at all. – jonrsharpe Oct 29 '21 at 14:29
  • @jonrsharpe the code run from unit test in question only examines `body`. So no, there won't be another missing property in the test. – mikemaccana Oct 29 '21 at 14:29
  • @mikemaccana that's totally irrelevant, because the compiler is erroring _before the value ever gets there_. This is not a runtime error, you're not being told that the body you defined actually isn't present, it's happening at compile time because it's not _required_ to be present by the type you're exposing `invalidEvent` to `handler` through. – jonrsharpe Oct 29 '21 at 14:30
  • @jonrsharpe Understood re: difference between compile and run time - thanks for your patience here, I've only been using TS a shorter time compared to JS and other dynamically typed languages. I've added the results from Pick/Omit to the question above. If I can find an answer that works without `as` I'll mark it as accepted. – mikemaccana Oct 29 '21 at 14:36
  • @mikemaccana there _isn't_ an answer that works without `as`, because fundamentally you're trying to build something that **is not an `APIGatewayProxyEvent`**, but pass it off as one. So you either have to provide every property, tell the compiler "no this is OK actually" (`as`), or tell the compiler to ignore it entirely (`@ts-ignore`). – jonrsharpe Oct 29 '21 at 14:38
  • @jonrsharpe OK I understand what you're saying here - that `Partial` can't be a solution here. My understanding from reading https://stackoverflow.com/questions/37355719/using-partial-shape-for-unit-testing-with-typescript specifically this answer: https://stackoverflow.com/a/55141075/123671 is that this what Partial was designed to do. – mikemaccana Oct 29 '21 at 14:42
  • 1
    @mikemaccana then they go right ahead and do `service.sendData( data)`, which is just another syntax for [type assertions](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) like `service.sendData(data as YourInterface)`. – jonrsharpe Oct 29 '21 at 14:46