sam-goodwin / typesafe-dynamodb

TypeSafe type definitions for the AWS DynamoDB API
Apache License 2.0
205 stars 11 forks source link

Polymorphic input into UpdateItem. #46

Closed davit-b closed 1 year ago

davit-b commented 1 year ago

Hi Sam,

I want to present my use case, and how I've been using this library. It may be a dumb use case but I am a TS beginner.

I have these two types, here they are as TS native types, but I'm using scale-codec to build them.

type PersonFieldsTaggedUnion = {
    type: "user";
    name: string;
    age: number;
    occupation: string;
} | {
    type: "admin";
    name: string;
    age: number;
    role: string;
    direct_report: string;
}

type Person = {
    id: string;
    type: "user";
    name: string;
    age: number;
    occupation: string;
} | {
    id: string;
    type: "admin";
    name: string;
    age: number;
    role: string;
    direct_report: string;
}

and I instantiate my ddb client like so

export const client = DynamoDBDocument.from(
  new DynamoDBClient({
    region: TABLE_REGION,
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    },
  }),
) as TypeSafeDocumentClientV3<Person, typeof TABLE_PK>

export const Update = TypeSafeUpdateDocumentCommand<Person, typeof TABLE_PK, undefined>()

I make my call to do an UpdateItem operation using

const id = uuid()

const [updateExpression, expressionAttributeNames, expressionAttributeValues] = ddbUpdateUtil(
  input,
)

return dynamodb.send(
  new Update({
    TableName,
    Key: {
      id,
    },
    UpdateExpression: updateExpression,
    ExpressionAttributeNames: expressionAttributeNames,
    ExpressionAttributeValues: expressionAttributeValues,
    ReturnValues: DynamoDBReturnValue.ALL_NEW,
  }),
)

using the helper function

function getUpdateExpression(input: PersonFieldsTaggedUnion) {
  if (input.type === "admin") {
    return "SET #k0=:v0,#k1=:v1,#k2=:v2,#k3=:v3,#k4=:v4"
  } else {
    return "SET #k0=:v0,#k1=:v1,#k2=:v2,#k3=:v3"
  }
}

export function ddbUpdateUtil(input: PersonFieldsTaggedUnion) {
  let expressionAttributeNames: any = {}
  let expressionAttributeValues: any = {}

  // Map over input to omit the userId in the update expression.
  Object.entries(input).map(([key, value], index) => {
    expressionAttributeNames[`#k${index}`] = key
    expressionAttributeValues[`:v${index}`] = value
  })

  // Inferred type is ..... const updateExpression: "SET #k0=:v0,#k1=:v1,#k2=:v2,#k3=:v3,#k4=:v4" | "SET #k0=:v0,#k1=:v1,#k2=:v2,#k3=:v3"
  const updateExpression = getUpdateExpression(input)

  return [updateExpression, expressionAttributeNames, expressionAttributeValues] as const
}

Because the input object is polymorphic, either type: "user" or type: "admin", and has different fields, I'm trying to dynamically create the UpdateExpression, and the ExpressionAttributeNames with ExpressionAttributeValues.

When I tried to create the UpdateExpression dynamically, the type system was unable to recognize ExpressionAttributeNames and gave me some type errors. I resorted to setting UpdateExpression to a string literal, and TS seems to allow me to then set the AttributeNames and AttributeValues dynamically like above.

However, when I read the object that is returned after executing the Update promise, and specified in ReturnValues, the type system does not recognize the fields that the intersection of the two tag-to-union types that define Person.

In this case, it only recognizes id, age, name, type, but does not recognize occupation which is unique to User.

tddb-1

and then you can see the Typescript error here specifically calls out occupation as not existing...but the error shows occupation as existing in the 'SerializeObject<UndefinedToOptional<{ id: string; type: "user"; name: string; age: number; occupation: string; }>> | SerializeObject<UndefinedToOptional<{ id: string; type: "admin"; name: string; age: number; role: string; direct_report: string; }>>' I don't understand this at all.

tddb-2

but I have confirmed that the ReturnValues object .Attributes does contain the occupation field.

tddb-return

What I've resorted to:

So I've concluded that I cannot use this tagged-to-union thing with the UpdateItem operation. I can only use the tag-to-union type with PutItem or with GetItem.

I guess I have to hard-code the update expression in my call to dynamodb. I assume this is best practice? I don't know.

The ask to the library owner.

  1. Is there a better way to model these kinds of polymorphic types and generate dynamic UpdateExpressions that work with the type system and with typesafe-ddb?

  2. Is the specific problem I have shown above about the type system not recognizing the Attributes inside the ReturnValues as problem with the way I have defined the Update command to dynamo?

github-actions[bot] commented 1 year ago

This issue is now marked as stale because it hasn't seen activity for a while. Add a comment or it will be closed soon. If you wish to exclude this issue from being marked as stale, add the "backlog" label.

github-actions[bot] commented 1 year ago

Closing this issue as it hasn't seen activity for a while. Please add a comment @mentioning a maintainer to reopen. If you wish to exclude this issue from being marked as stale, add the "backlog" label.