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:
CodePudding user response:
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!