Home > Software design >  Referencing the index of a generic type in TypeScript
Referencing the index of a generic type in TypeScript

Time:11-21

I'm trying to create generic message types, with ever-narrower types, the most narrow of-which is actually inferred during the calling of a function or creation of a class.

Take this code:

type MessageHeaderType = 'REQUEST'|'RESPONSE'|'UPDATE'|'ERROR'|'EVENT'

type UnknownRecord = object

interface Message<MessageBody = UnknownRecord> {

  header: {
    id?: string
    type: MessageHeaderType
  },
  body: MessageBody

}

interface MessageUpdate<MessageBody = MessageUpdateBody> extends Message<MessageBody> {

  header: {
    id: string
    type: 'UPDATE'
  }
}

interface MessageUpdateBody {

  code: string
  data?: UnknownRecord

}

This works nicely and I can create a MessageUpdate easily:

const ExampleMessageUpdate: MessageUpdate = {

  header: {
    id: '123',
    type: 'UPDATE'
  },
  body: {
    code: 'SOME_UPDATE',
    data: {
      foo: 'bar'
    }
  }
} // <== All good!

The problem comes when I allow a user of said code to generate their own message update body "on the fly" (still compile-time, obvs!). The below code does not let me index the generic that is passed in their implementation of a particular MessageUpdate type:

function createMessage<ThisMessageUpdateBody>(code: string, data?: ThisMessageUpdateBody['data']): MessageUpdate<ThisMessageUpdateBody> {

  const message: MessageUpdate<ThisMessageUpdateBody> = {
    header: {
      id: '123',
      type: 'UPDATE'
    },
    body: {
      code,
      data
    }
  }

  return message
  
}

I get the error on the function parameters:

Type '"data"' cannot be used to index type 'ThisMessageUpdateBody'

How can I achieve a further narrowing of this type, in the same manner, without an error?

CodePudding user response:

Update

Here's a slightly changed version to accommodate OP's comment.

If using an object as a single parameter of the function is not an option, then at least I would go with type aliases for keeping it safe and also staying away from string indexes.

type BodyCodeType = string;
type BodyDataType = UnknownRecord;

interface MessageUpdateBody {
  code: BodyCodeType;
  data?: BodyDataType;
}

interface MessageUpdate<MessageBody = MessageUpdateBody>
  extends Message<MessageBody> {
  header: {
    id: string;
    type: 'UPDATE';
  };
}

function createMessage<ThisMessageUpdateBody extends MessageUpdateBody>(
  code: BodyCodeType,
  data: BodyDataType
): MessageUpdate<ThisMessageUpdateBody> {
  const message: MessageUpdate<ThisMessageUpdateBody> = {
    header: {
      id: '123',
      type: 'UPDATE',
    },
    body: {
      code,
      data,
    } as ThisMessageUpdateBody,
  };

  return message;
}

const customMsg = createMessage('test_code', { foo: 'bar' });

console.log(customMsg); // OK

Btw, you still need the as ThisMessageUpdateBody cast, otherwise the compiler will get confused on the body:

enter image description here


Personally I would rely on data inference from the actual template type, and so I would use an object for the function parameter. Like this:

function createMessage<ThisMessageUpdateBody extends MessageUpdateBody>({
  code,
  data,
}: ThisMessageUpdateBody): MessageUpdate<ThisMessageUpdateBody> {
  const message: MessageUpdate<ThisMessageUpdateBody> = {
    header: {
      id: '123',
      type: 'UPDATE',
    },
    body: {
      code,
      data,
    } as ThisMessageUpdateBody,
  };

  return message;
}

const customMsg = createMessage({
  code: 'test_code',
  data: { foo: 'bar' },
});

And not using strings for indexing is another win, because now if you change from data to payload inside MessageUpdateBody, it will throw a clear compile time error.

CodePudding user response:

One way to refer the index of generic type is to create another generic and assign this generic as the type, so in your case it could be something like

function createMessage<ThisMessageUpdateBody extends MessageUpdateBody, Data extends ThisMessageUpdateBody['data']>(code: string, data?: Data): MessageUpdate<ThisMessageUpdateBody> {
    const body = {
        code,
        data
    };
  const message = {
    header: {
      id: '123',
      type: UPDATE as typeof UPDATE
    },
    body
  }

  return message
  
}

Although above snippets still throws an error because ThisMessageUpdateBody could be of any sub type of MessageUpdateBody i.e it could contains many other properties apart from code & data (below is the error).

Type '{ code: string; data: Data | undefined; }' is not assignable to type 'ThisMessageUpdateBody'.
      '{ code: string; data: Data | undefined; }' is assignable to the constraint of type 'ThisMessageUpdateBody', but 'ThisMessageUpdateBody' could be instantiated with a different subtype of constraint 'MessageUpdateBody'.(2322)

Suggestion 1:

createMessage to have body parameter instead of code& data

function createMessage<ThisMessageUpdateBody extends MessageUpdateBody= MessageUpdateBody>(body: ThisMessageUpdateBody): MessageUpdate<ThisMessageUpdateBody> {
  const message = {
    header: {
      id: '123',
      type: UPDATE as typeof UPDATE
    },
    body
  }

  return message
  
}

Suggestion 2:

instead of declaring generic type of messageBody createMessage could declare generic type of body data. This lets us fulfil the constraint that message body would always have two properties (code: string & data: CustomData)

// MessageBody declare generic for "body.data" type
interface MessageUpdateBody<MessageBodyData = UnknownRecord> {

  code: string
  data?: MessageBodyData

}

function createMessage<MessageBodyData = UnknownRecord>(code: string, data: MessageBodyData): MessageUpdate<MessageUpdateBody<MessageBodyData>> {
  const message = {
    header: {
      id: '123',
      type: UPDATE as typeof UPDATE
    },
    body: {
        code,
        data
    }
  }

  return message
  
}

Above TS snippet playground link

  • Related