Search code examples
typescript

Enforce interface for array of objects and also create type from a mapped value


I'm using typescript to make sure queues fulfill the IQueue interface:

export interface IQueue {
  id: string;
  handler: () => void;
}

const queues:IQueue[] = [
  { id: 'a', handler: () => { } },
  { id: 'b' }, // handler is missing, should be an error
];

I also want a QueueId type which is a union of all the ids:

const queues = [
  { id: 'a', handler: () => { } },
  { id: 'b' },
] as const;


export declare type QueueId = (typeof queues[number])['id'];

export const start = (queueId:QueueId) => {
  ...
};

start('z'); // should be a typescript error

But I can't get them to work together. The QueueId type requires an as const type. Several posts recommend doing a noop cast but I get the readonly cannot be assigned to the mutable type... error. So I tried making it writeable but it gives an "insufficient overlap" error:

type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
(queues as DeepWriteable<typeof queues>) as IQueue[];

Is it possible to do both?

Here's a full example:

Playground


Solution

  • First, if you want the compiler to infer string literal types for the id properties without inferring a readonly tuple type for queues, then you can move the const assertion from the queues initializer to just the id properties in question:

    const queues = [
      {
        id: 'x' as const,
        handler: () => { },
      },
      {
        id: 'y' as const,
        handler: () => { },
      },
    ];
    
    /* const queues: ({
         id: "x";
         handler: () => void;
       } | {
         id: "y";
         handler: () => void;
       })[] */
    
    type QueueId = (typeof queues[number])['id'];
    // type QueueId = "x" | "y"
    

    At this point you want to check that queues's type is assignable to IQueue[] without actually actually annotating it as IQueue[], since that would make the compiler forget about "x" and "y" entirely.

    TypeScript doesn't currently have a built-in type operator to do this; there is a feature request for one (tentatively) called satisfies at microsoft/TypeScript#47920 where you would maybe write something like

    // this is not valid TS4.6-, don't try it:
    const queues = ([
      {
        id: 'x' as const,
        handler: () => { },
      },
      {
        id: 'y' as const,
        handler: () => { },
      },
    ]) satisfies IQueue[];
    

    And then the compiler would complain if you left out a handler or something. But there is no satisfies operator.

    Luckily you can essentially write a helper function which (if you squint at it) behaves like a satisfies operator. Instead of writing x satisfies T, you'd write satisfies<T>()(x). Here's how you write it:

    const satisfies = <T,>() => <U extends T>(u: U) => u;
    

    That extra () in there is because satisfies is a curried function in order to allow you to specify T manually while having the compiler infer U. See Typescript: infer type of generic after optional first generic for more information.

    Anyway, when we use it, we can see that it will complain if you mess up:

    const badQueues = satisfies<IQueue[]>()([
      {
        id: 'x' as const,
        handler: () => { },
      },
      { id: 'y' as const }, // error!
      // ~~~~~~~~~~~~~~~~~ <-- Property 'handler' is missing
    ]);
    

    And when you don't mess up, it doesn't forget about 'x' and 'y':

    const queues = satisfies<IQueue[]>()([
      {
        id: 'x' as const,
        handler: () => { },
      },
      {
        id: 'y' as const,
        handler: () => { },
      },
    ]);
    
    /* const queues: ({
         id: "x";
         handler: () => void;
       } | {
         id: "y";
         handler: () => void;
       })[]
    */
    
    type QueueId = (typeof queues[number])['id'];
    // type QueueId = "x" | "y"
    

    Looks good!

    Playground link to code