4

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

4
  • Does this approach meet your needs? If so I can write up an answer; if not, what am I missing? Commented Mar 1, 2022 at 1:59
  • Wow, yeah - amazing. And here I thought I was getting good at typescript. I'll have to study the asQueues magic Commented Mar 1, 2022 at 2:39
  • Okay I'll write up an answer explaining it when I get a chance. Commented Mar 1, 2022 at 2:44
  • I opted for a more general approach that strays less from what you were doing. If you really want me to write up how asQueues() works I can (but maybe not today) Commented Mar 1, 2022 at 4:13

2 Answers 2

2

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

Sign up to request clarification or add additional context in comments.

Comments

0

This is a common pattern for me. This is the best I can do now that Typescript 4.9 supports the satisfies operator.

interface IQueueBase<K extends string = string> {
  id: K;
  title: string;
   ...
}

const all = [
  { id: 'steady' as const, title: 'Steady' },
  { id: 'rising' as const, title: 'Rising' },
  { id: 'falling' as const, title: 'Falling' },
] satisfies IQueueBase[];

export type IQueueId = typeof all[number]['id'];
export type IQueue = IQueueBase<IQueueId>;

...

Still not pleased with the noise, so any improvements are welcome.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.