Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is it possible to get an array types of the record's values in Typescript?

I want to obtain all the types of the record's values. For example if I have an object like this:

{
  'a': 1,
  'b': false
}

I would like to have a type which contains number and boolean.

I'm trying to achieve some sort of function mappings without losing types:

type MyContext = { store: number[]; };

const obj: Record<string, (context: MyContext, ...args: any[]) => void> = {
    log(ctx: MyContext, msg: string) {
        console.log(msg);
    },
    add(ctx: MyContext, value: number) {
        ctx.store.push(value);
    }
};

const convert = <TContext>(context: TContext, objectWithFunctions: Record<string, (context: TContext, ...args: any[]) => void>) => {
    //omit the first parameter and return a new object with modified functions
    const result: Record<string, (...args: any[]) => void> = {};

    Object.keys(objectWithFunctions).forEach((functionName) => {
        const func = objectWithFunctions[functionName];
        result[functionName] = (...args) => func(context, ...args);
    });

    return result;
};

const context = { store: [] };
const newObj = convert(context, obj);
newObj.log('some message');
newObj.add(12);
newObj.add();//should give an error
newObj.foo();//should give an error
console.log(newObj, context.store);

Playground

This implementation works but it's lacking of types because any[] constraint is used for the second argument in all the object's functions. Is it a way to somehow infer the types and return some more stricter type rather than Record<string, (...args: any[]) => void>?

like image 666
Roman Koliada Avatar asked Oct 28 '25 00:10

Roman Koliada


1 Answers

First of all, you can't annotate obj as being of type Record<string, (context: MyContext, ...args: any[]) => void> without throwing away all information about the particular method names and arguments in the initializer. If you want the output type of convert(context, obj) to know about log and add, then you should probably just assign to obj without annotating at all; let the compiler infer its type:

const obj = {
    log(ctx: MyContext, msg: string) {
        console.log(msg);
    },
    add(ctx: MyContext, value: number) {
        ctx.store.push(value);
    }
};

Later, if the compiler doesn't complain about calling convert(context, obj), then you know obj is of an appropriate type.


Next, in order for convert() to strongly type its output, it must be generic not only in T, the type of context, but also in a type parameter related to the method name/argument mapping in objectWithFunctions. My approach here would be to make the new type parameter A be an object type whose keys are the method names, and whose values are the argument lists not including the initial context parameter of type T.

For example, for obj, A would be specified as

{
    log: [msg: string];
    add: [value: number];
}

Note that you will never actually be using a value of type A, but it will be inferrable from the objectWithFunctions input, and it's straightforward to represent both the type of objectWithFunctions and the return type in terms of A. Here are the typings:

const convert = <T, A extends Record<keyof A, any[]>>(
    context: T,
    objectWithFunctions: { [K in keyof A]: (context: T, ...rest: A[K]) => void }
) => {
    const result = {} as { [K in keyof A]: (...args: A[K]) => void };

    (Object.keys(objectWithFunctions) as Array<keyof A>)
        .forEach(<K extends keyof A>(functionName: K) => {
            const func = objectWithFunctions[functionName];
            result[functionName] = (...args) => func(context, ...args);
        });

    return result;
};

So, objectWithFunctions's type is a mapped type where the array A[K] in each property K of A is transformed to a function type with an argument of type T followed by a rest argument of type A[K]. It turns out that because this type is of the form {[K in keyof A]...}, it is a homomorphic mapped type and the compiler is good at inferring from homomorphic mapped types. The return type, as given in the annotation of result, is the same mapped type without the initial T argument.

Inside the body of the function I had to use a few type assertions to convince the compiler that some values had certain types. The initial value of result is an empty object, so I had to assert that it would be the final type (because {} certainly isn't of that type). And the return type of Object.keys() is just string[] (see Why doesn't Object.keys return a keyof type in TypeScript?) so I had to assert that it returns an array of keyof A.


Okay, let's test it:

const context: MyContext = { store: [] }; 

It's useful to annotate context here as MyContext, since otherwise the compiler assumes that store will always be an empty array (of type never[]). And here goes:

const newObj = convert(context, obj);        

You can use Quickinfo via IntelliSense to see that the call to convert() caused the compiler to infer MyContext for T and the aforementioned { log: [msg: string]; add: [value: number];} type for A:

/* const convert: <MyContext, {
    log: [msg: string];
    add: [value: number];
}>(context: MyContext, objectWithFunctions: {
    log: (context: MyContext, msg: string) => void;
    add: (context: MyContext, value: number) => void;
}) => {
    ...;
} */

And when you hover on myObj, you can see that it is exactly the type you want it to be:

/* const newObj: {
    log: (msg: string) => void;
    add: (value: number) => void;
} */

So it behaves as desired:

newObj.log('some message');
newObj.add(12);
newObj.add(); // error
newObj.foo(); // error
console.log(newObj, context.store); // {}, [12]

Playground link to code

like image 98
jcalz Avatar answered Oct 31 '25 01:10

jcalz