Home > front end >  Create an object that has a method which parameter has the same type as a field in that same object
Create an object that has a method which parameter has the same type as a field in that same object

Time:01-02

I have an array of objects - commands for my game. Each object has an input field which is supposed to represent the types of each argument passed into the command. This way I can do some validation on the arguments. I then have a resolve function, that runs once all the validation has passed. This resolve function contains a parameter with an array of all the command arguments passed in, correctly parsed. This issue is that I lose all the type safety inside the resolve() function, since the types of the array are all string, since that is how the argument were passed in. But I know that the first parameter is 100% a string union or a number because I ran it through the validation and it didn't throw an error. But the validation is done outside of the resolve function. Here is an example of one object

const commands = [{
name: "setscore",
// First argument must be an element in the array, second argument must be a number
input: [["b", "blue", "r", "red"] as const, Number()],
resolve({input}) {

// I lost all the validation now, all these arguments are strings
const [teamName, score] = input

// Now when I want to use these arguments in certain functions, I have to cast them again
}
}]

Instead of the input being a string[] I want it to be

["b" | "blue" | "r" | "red", number]

This obviously depends on the command, since each command accepts different arguments. I got this idea from trpc, since they use a similar approach to get type safety in their mutations, but I am at a loss when looking through their documentation.

How do I achieve this casting? My first approach was just creating a type with a Generic, but that generic is later required, and won't work for arrays.

type Command<T> {
name: string,
input: T,
resolve({input}: {input: T}) => void
}

CodePudding user response:

The most straightforward approach here is to make Command generic in the type of input, as you showed:

type Command<T> = {
    name: string,
    input: T,
    resolve({ input }: { input: T }): void
}

Then, yes, the generic type argument is required later, but this is actually a good thing if you'd like to be able to call resolve() with inputs other than the input property in the command.

But just because the type argument is required, it doesn't mean you need to specify it manually. You can write generic helper functions to infer it. For example, with this function:

const asCommand = <T,>(c: Command<T>) => c;

You can infer the type argument of a particular command:

const command = asCommand({
    name: "setscore",
    input: [["b", "blue", "r", "red"], Number()] as const,
    resolve({ input }) {
        const [teamName, score] = input
        // const teamName: readonly ["b", "blue", "r", "red"]
        // const score: number
    }
});
//const command: Command<readonly [readonly ["b", "blue", "r", "red"], number]>

Here the compiler infers T as readonly [readonly ["b", "blue", "r", "red"], number] (this happens because of the const assertion on input, which I moved so that the compiler knows that input is itself a tuple).

And with this helper function:

const asCommandArray = <T extends any[]>(
    ...arr: { [I in keyof T]: Command<T[I]> }) => arr;

You can infer an array of commands, each with a different type argument, via inference from mapped array types:

const arr = asCommandArray(
    { name: "x", input: "abc", resolve({ input }) { console.log(input.toUpperCase()) } },
    { name: "y", input: 123, resolve({ input }) { console.log(input.toFixed()) } }
)
// const arr: [Command<string>, Command<number>]

That should hopefully meet your needs.


Again, the above assumes that you actually do care about the type of input and therefore you need to keep track of the type argument T for each command object, because, say, you want to call command.resolve() with a variety of inputs. If, on the other hand, you were only ever going to call it with command.input, then you could refactor to not need a generic type argument. Conceptually this would be an existentially quantified generic of the form Command<exists T> where exists T means that a T exists but you don't know or care what it is. TypeScript doesn't directly support existential types (and few languages do), see microsoft/TypeScript#14466. But you can encode them in the language by some inversion of control:

type SomeCommand = <R, >(cb: <T, >(command: Command<T>) => R) => R;
const someCommand = <T,>(command: Command<T>): SomeCommand => cb => cb(command);

const sc = someCommand({
    name: "setscore",
    input: [["b", "blue", "r", "red"], Number()] as const,
    resolve({ input }) {
        const [teamName, score] = input
        console.log("teamName", teamName, "score", score.toFixed(1));
    }
});

Now sc is of type SomeCommand, and the only way to access its properties is by passing it a generic callback:

sc(command => command.resolve({ input: command.input }));
// "teamName",  ["b", "blue", "r", "red"],  "score",  "0.0" 

But honestly, at that point, the only useful thing you are doing with resolve() is calling it with the one input in the world that works, so you might as well refactor to something easier:

type SomeCommand = { name: string, callResolve(): void }
const someCommand = <T,>({ name, resolve, input }: Command<T>): SomeCommand =>
    ({ name, callResolve: () => resolve({ input }) });

const sc = someCommand({
    name: "setscore",
    input: [["b", "blue", "r", "red"], Number()] as const,
    resolve({ input }) {
        const [teamName, score] = input
        console.log("teamName", teamName, "score", score.toFixed(1));
    }
});
sc.callResolve();
// "teamName",  ["b", "blue", "r", "red"],  "score",  "0.0" 

That exposes only a zero argument callResolve() method instead of the resolve/input pair, and callResolve() does the one thing you could have safely done with them anyway.


So either you should make Command generic and keep track of the generic argument, even though this takes some effort, or you should make Command specific but refactor it so that it exposes only the non-generic functionality you need.

Playground link to code

  • Related