TypeScript: Validate mapped types and const context
Mapped types are great, as they allow for the flexibility in object structures JavaScript is known for. But they have some crucial implications on the type system. Take this example:
type Messages =
'CHANNEL_OPEN' | 'CHANNEL_CLOSE' | 'CHANNEL_FAIL' |
'MESSAGE_CHANNEL_OPEN' | 'MESSAGE_CHANNEL_CLOSE' |
'MESSAGE_CHANNEL_FAIL'
type ChannelDefinition = {
[key: string]: {
open: Messages,
close: Messages,
fail: Messages
}
}
This is from a generic messaging library, that takes a “channel definition” where multiple channel tokens can be defined. The keys from this channel definition object are what the user wants it to be. So this is a valid channel definition:
const impl: ChannelDefinition = {
test: {
open: 'CHANNEL_OPEN',
close: 'CHANNEL_CLOSE',
fail: 'CHANNEL_FAIL'
},
message: {
open: 'MESSAGE_CHANNEL_OPEN',
close: 'MESSAGE_CHANNEL_CLOSE',
fail: 'MESSAGE_CHANNEL_FAIL'
}
}
We have a problem when we want to access the keys we defined so flexibly. Let’s say we have a function that opens a channel. We pass the whole channel definition object, as well as the channel we want to open.
declare function openChannel(
def: ChannelDefinition,
channel: keyof ChannelDefinition
)
So what are the keys of ChannelDefinition
? Well, it’s every key: [key: string]
. So the
moment we assign a specific type, TypeScript treats impl
as this specific type, ignoring
the actual implementation. The contract is fulfilled. Moving on. This allows for wrong keys to
be passed:
// Passes, even though "massages" is no part of impl
openChannel(impl, 'massages')
So we are more interested in the actualy implementation, not the type we assing to our constant.
This means we have to get rid of the ChannelDefinition
type and make sure we care about the
actual type of the object.
First, the openChannel
function should take any object that is a subtype of ChannelDefinition
,
but work with the concrete subtype:
- declare function openChannel(
- def: ChannelDefinition,
- channel: keyof ChannelDefinition
- )
+ declare function openChannel<T extends ChannelDefinition>(
+ def: T,
+ channel: keyof T
+ )
TypeScript now works on two levels:
- Checking if
T
actually extendsChannelDefinition
. If so, we work with typeT
- All our function parameters are typed with the generic
T
. This also means we get the real keys ofT
throughkeyof T
.
To benefit from that, we have to get rid of the type definition for impl
. The explicit
type definition overrides all actual types. From the moment we explicitly specify the type,
TypeScript treats it as ChannelDefinition
, not the actual underlying subtype. We also have
to set const
context, so we can convert all strings to their unit type (and thus be compliant
with Messages
):
- const impl: ChannelDefinition = { ... };
+ const impl: { ... } as const;
Without const
context, the inferred type of impl
is:
/// typeof impl
{
test: {
open: string;
close: string;
fail: string;
};
message: {
open: string;
close: string;
fail: string;
};
}
With const
context, the actual type of impl
is now:
/// typeof impl
{
test: {
readonly open: "CHANNEL_OPEN";
readonly close: "CHANNEL_CLOSE";
readonly fail: "CHANNEL_FAIL";
};
message: {
readonly open: "MESSAGE_CHANNEL_OPEN";
readonly close: "MESSAGE_CHANNEL_CLOSE";
readonly fail: "MESSAGE_CHANNEL_FAIL";
};
}
const
context allows us to satisfy the contract made by
ChannelDefinition
. Now, openChannel
correctly errors:
openChannel(impl, 'messages') // ✅ satisfies contract
openChannel(impl, 'massages') // 💥 bombs
You might be in a space where you need to work with the concrete type, that satisfies
the ChannelDefinition
contract, outside of a function. For that, we can mimic the same
behaviour using the Validate<T, U>
helper type:
type Validate<T, U> = T extends U ? T : never;
Use this as follows:
const correctImpl = {
test: {
open: 'CHANNEL_OPEN', close: 'CHANNEL_CLOSE', fail: 'CHANNEL_FAIL'
}
} as const;
const wrongImpl = {
test: {
open: 'OPEN_CHANNEL', close: 'CHANNEL_CLOSE', fail: 'CHANNEL_FAIL'
}
} as const;
// ✅ returns typeof correctImpl
type ValidatedCorrect
= Validate<typeof correctImpl, ChannelDefinition>;
// 💥 returns never
type ValidatedWrong
= Validate<typeof wrongImpl, ChannelDefinition>;
As always, there’s a pen for you to fiddle around.