Home > other >  TypeScript - How to use a runtime mapping of data instead of an interface?
TypeScript - How to use a runtime mapping of data instead of an interface?

Time:10-18

The following is a short code snippet that defines two command handlers for a server:

import { plainToClass } from "class-transformer";

enum Command {
  COMMAND_1,
  COMMAND_2,
}

class Command1Data {
  foo1!: string
}

class Command2Data {
  foo2!: number
}

const CommandDataMap = {
  [Command.COMMAND_1]: Command1Data,
  [Command.COMMAND_2]: Command2Data,
}

function command1Function(data: Command1Data) {
  console.log("Server got command 1!", data)
}

function command2Function(data: Command2Data) {
  console.log("Server got command 2!", data)
}

type CommandMap = {
  [Value in Command]: (
    data: CommandDataMap[Value], // Compiler error on this line
  ) => void;
};

const commandMap: CommandMap = {
  [Command.COMMAND_1]: command1Function,
  [Command.COMMAND_2]: command2Function,
}

export function onIncomingData(possibleCommand: string) {
  const command = possibleCommand as unknown as Command;
  const commandFunction = commandMap[command];
  if (commandFunction === undefined) {
    console.error("Invalid command:", command);
    return;
  }

  const dataClass = CommandDataMap[command];
  const validatedData = plainToClass(dataClass, data, {
    strategy: "exposeAll",
  });
  commandFunction(validatedData);
}

The idea here is to use the "class-transformer" library from NPM to turn the raw data into a class, and validate it in the process.

The compiler says to change CommandDataMap to typeof CommandDataMap, but then the compiler complains that the function signatures don't match anymore, so I am very confused. Can anyone explain what is going on?

I can get the code to compile by changing const CommandDataMap = { to interface CommandDataMap {. However, this breaks my program, because TypeScript deletes interfaces at runtime, and I need the mapping to exist at runtime so that I can perform validation on incoming data. Another option is to completely copy-paste the entire thing into a runtime version and a non-runtime version, but this is terrible and obviously prone to breaking.

It seems like the compiler should be smart enough to read the type of the object and use that instead of an interface?

CodePudding user response:

Fundamentally I think your problem is that you're not properly distinguishing between types and values in TypeScript. TypeScript types exist only in the static type system and are not present at runtime, while JavaScript values do exist at runtime. Values can be said to have types (or be of a type, or inhabit a type), but they are not types themselves. In TypeScript some expressions refer to types and others refer to values, and these are determined syntactically from context and not by name. In the following code:

const Foo: Bar = Baz as Qux;

Foo and Baz refer to values, and Bar and Qux refer to types, purely based on syntax. A named type can have the same name as a completely unrelated named value; the fact that they have the same name does not imply any relationship between them. So you could also write:

const Bar: Foo = Qux as Baz;

and the names Foo, Bar, Baz and Qux could refer to completely different things from the prior statement. For example:

interface Foo {
    a: string;
}
interface Bar {
    b: number;
}
interface Baz extends Foo {
    c: boolean;
}
interface Qux extends Bar {
    d: Date
}
const Baz = { d: new Date() };
const Qux = { c: true };

const Foo: Bar = Baz as Qux;
const Bar: Foo = Qux as Baz;

You can see that the type Baz is something like {a: string, c: boolean} while the value Baz is {d: new Date()}. So the type of (the value) Baz is not related to (the type) Baz at all:

const nope: Baz = Baz; // error
// Type '{ d: Date; }' is missing the following properties from type 'Baz': c, a

Also note that if you have a named value, you can use the typeof type operator to get the type of that value in TypeScript:

type TypeofBaz = typeof Baz;
// type TypeofBaz = {  d: Date; }

and, again, if there's a type named Baz and a value named Baz, there's nothing saying typeof Baz and Baz need to be related in any way.


Class statements like class Command1Data { foo1!: string } bring both a named value and a named type into scope. The named value is a class constructor, while the named type is the type of instances of the class. So the type Command1Data is essentially the same as {foo1: string}, while the type of the value Command1Data (a.k.a. typeof Command1Data) is essentially the same as new() => Command1Data.

This is a common source of confusion about the difference between named values and named types. There are many situations in which XXX is both a value and a type, and where typeof XXX is essentially the same as new () => XXX, but this only happens to be true in the case where you have a class declaration of the form class XXX. You can't rely on it to be true in general. It doesn't even make sense when XXX is a generic type parameter, since there probably is no value named XXX at all.


With that all out of the way, let's look at your original definition of CommandMap:

type CommandMap = {
    [Value in Command]: (
        data: CommandDataMap[Value] // error
    ) => void;
};

You need to annotate the data function parameter with a type. The form of CommandDataMap[Value] implies that you are indexing into a type CommandDataMap with a key of type Value.

The first problem is that there is no type named CommandDataMap. You have a value with that name, but there is no built in relationship between types and values. If you are trying to consult the CommandDataMap value to define CommandMap, you will need to use typeof CommandDataMap to get its type:

type CommandMap = {
    [Value in Command]: (
        data: typeof CommandDataMap[Value] 
    ) => void;
};

const commandMap: CommandMap = {
    [Command.COMMAND_1]: command1Function, // error
    [Command.COMMAND_2]: command2Function, // error
}

Now you are referring to a type, but you still have an error. Let's inspect CommandMap to see why:

/* type CommandMap = {
  0: (data: typeof Command1Data) => void;
  1: (data: typeof Command2Data) => void;
} */

Note that typeof Command1Data is the type of the value named Command1Data, which is a class constructor. That makes sense; CommandDataMap holds class constructors, not instances. So you have said that CommandMap's property values are callbacks accepting class constructors. That's apparently not what you want. You want your callbacks to accept class instances.

So you need to take these constructor types and turn them into the appropriate instance types. You can do this with the InstanceType<T> utility type:

type X = InstanceType<typeof Command1Data>;
// type X = Command1Data

(Again, the fact that the class constructor value is named Command1Data and the instance type is also named Command1Data is not generalizable. You shouldn't think "XXX is the same as InstanceType<typeof XXX>"; it's not true and often doesn't make sense.)

So typeof CommandDataMap[Value] is a constructor type, and the corresponding instance type is InstanceType<typeof CommandDataMap[Value]>:

type CommandMap = {
    [Value in Command]: (
        data: InstanceType<typeof CommandDataMap[Value]>
    ) => void;
};

And now your commandMap definition is accepted:

const commandMap: CommandMap = {
    [Command.COMMAND_1]: command1Function,
    [Command.COMMAND_2]: command2Function,
} // okay

Playground link to code

  • Related