'Compute Types From String
I'm working on a game with client/server communication. The client and server communicate with each other on different "Channels," which have arbitrary names and bodies. To avoid having to memorize channel names and required parameters for each type of request, I made the following interface:
export interface Channel {
name: string;
message_format: string;
}
Now I can define channels as so:
export const Channels = {
player_id: {
name: 'PID',
message_format: '{id}'
} as Channel,
join_request: {
name: 'JOIN',
message_format: '{roomId}:{username}',
} as Channel
};
I also have the following functions to help create and parse requests:
export function createMessage(channel: Channel, args: { [key: string]: string }) {
let msg = channel.message_format;
for (let key in args) {
msg = msg.replace(`{${key}}`, `{${args[key]}}`);
}
return msg;
}
export function parseMessage(channel: Channel, msg: string) {
const args = {} as { [key: string]: string };
const regex = new RegExp(`{(.*?)}`, 'g');
let formatMatch;
const spl = msg.split(':');
let index = 0;
while ((formatMatch = regex.exec(channel.message_format)) !== null) {
args[formatMatch[1]] = spl[index++].replaceAll('{', '').replaceAll('}', '');
}
return args;
}
Currently, my Join Request Listener looks like this:
function onJoinRequest(msg: { roomId: string, username: string }) {
console.log(msg);
}
The Listener must be invoked like this:
onJoinRequest(parseMessage(Channels.join_request, "{myRoomId}:{myUsername}") as { roomId: string, username: string });
My problem is that I had to define the contents of the Join Request three times:
In Channels, where I define message format as
{roomId}:{username}In the function definition of
onJoinRequest, where I definedmsgas{ roomId: string, username: string }When invoking
onJoinRequestwhere I assert that the object returned byparseMessage()is of the type{ roomId: string, username: string }.
I want Typescript to be able to infer the type of msg from Channels.join_request.message_format, because I only want to define the parameters of a message body once. Because parseMessage() will always return an object in the format defined in message_format, I feel like defining the type of msg three times is bad programming practice.
Is there any way to have Typescript infer the type of msg in the listener based on the contents of message_format.
Solution 1:[1]
So, you want the compiler to look at a string literal like '{roomId}:{username}' and extract 'roomId' and 'username' from it to use as keys. This is possible using template literal types.
First, in order to have any hope of doing this, you need to preserve the literal types of the properties in Channels (or at least the message_format properties). In your original definition, the type of message_format was inferred to be string by the compiler. That's often what people want when they use string literals, but it's not desirable here. One way to do this is to use a const assertion when defining Channels:
const Channels = {
player_id: {
name: 'PID',
message_format: '{id}'
},
join_request: {
name: 'JOIN',
message_format: '{roomId}:{username}',
}
} as const;
This tells the compiler to treat Channels as being as specific and immutable as possible. Here's the type of Channels now:
/* const Channels: {
readonly player_id: {
readonly name: "PID";
readonly message_format: "{id}";
};
readonly join_request: {
readonly name: "JOIN";
readonly message_format: "{roomId}:{username}";
};
} */
Great, now we can write a type function MessageFormatKeys<T> which takes a message_format-styled string literal type T and produces a union of the keys contained within. You didn't specify exactly what the rule is, so I'm going to assume that we can just grab whatever we find between pairs of curly braces. Here's one way to write it:
type MessageFormatKeys<T extends string, KS extends string = never> =
T extends `${infer F}{${infer K}}${infer R}` ?
MessageFormatKeys<R, K | KS> : KS
This is a tail-recusive conditional type which starts with a string T and an accumulated list of previously-extracted keys KS (which we initialize to never). Given the string T we see if we can break it into chunks:
- everything before the first open curly brace
"{"; call this chunkF(the First chunk) - the open curly brace
"{" - everything before the next close curly brace
"}"; call this chunkK(the Key) - the close curly brace
"}" - everything after that; call this chunk
R(the Rest of the string).
If so, we discard F and the curly braces, and recursively compute MessageFormatKeys<R, K | KS>; that is, we add the key K to the union in the accumulated list, and continue processing with the rest of the string.
If we can't break up the string T into those chunks, then there are no more keys and we just return the union of keys accumulated so far.
Let's make sure it behaves how we want:
type Test1 = MessageFormatKeys<"{id}">;
// type Test1 = "id"
type Test2 = MessageFormatKeys<"{roomId}:{username}">;
// type Test2 = "roomId" | "username"
type Test3 = MessageFormatKeys<" blah blah {key1} blah blah {key2} etc {key3} ho hum">
// type Test3 = "key1" | "key2" | "key3"
type Test4 = MessageFormatKeys<"{abc{def}ghi}">
// type Test4 = "abc{def"
The first two are exactly what you want. The others are the logical consequences of the implementation of MessageFormatKeys; it doesn't really care about anything not encased in curly braces, and you can't nest curly braces because.
Once we have MessageFormatKeys<T> we can write MessageType<T> which is an object types with keys in MessageFormatKeys<T> whose property types are all strings:
type MessageType<T extends string> =
{ [P in MessageFormatKeys<T>]: string };
type Test5 = MessageType<"{roomId}:{username}">;
/* type Test5 = {
roomId: string;
username: string;
} */
Now that we have the tools to describe the types we want, we need to make createMessage() and parseMessage() use them. This involves changing them to have generic call signatures. Here's one way to do it:
function createMessage<T extends Channel>(
channel: T, args: MessageType<T["message_format"]>): string;
function parseMessage<T extends Channel>(
channel: T, msg: string): MessageType<T["message_format"]>;
We give the channel input the generic type T which is constrained to Channel, from which the args input to createMessage() and the output of parseMessage() is given the type MessageType<T["message_format"]>, meaning that we use the message_format property type of T to determine the keys.
If you try to just change the call signatures you'll start seeing errors in the implementations; the compiler is just not clever enough to verify that the functions actually do what the call signature claims to do. One way to get this working without changing the implementation is to make the functions single-call-signature overloads; you declare the call signature first, and you implement it immediately afterward. For example:
// call signature
function createMessage<T extends Channel>(
channel: T, args: MessageType<T["message_format"]>): string;
// implementation
function createMessage(channel: Channel, args: { [k: string]: string }) {
let msg = channel.message_format;
for (let key in args) {
msg = msg.replace(`{${key}}`, `{${args[key]}}`);
}
return msg;
}
Let's test it out!
const msg = parseMessage(Channels.join_request, "{myRoomId}:{myUsername}"); // okay
msg.roomId.toUpperCase(); // okay
msg.username.toUpperCase(); // okay
msg.oops; // error!
// Property 'oops' does not exist on type 'MessageType<"{roomId}:{username}">'
createMessage(Channels.join_request, msg); // okay
createMessage(Channels.player_id, msg); // error!
// Argument of type 'MessageType<"{roomId}:{username}">' is not
// assignable to parameter of type 'MessageType<"{id}">'.
Looks good. The compiler knows that msg is of type MessageType<"{roomId}:{username}"> which has string-valued roomId and username properties. And thus it accepts it in createMessage(Channels.join_request, msg) and rejects it in createMessage(Channels.player_id, msg).
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | jcalz |
