Home > Mobile >  Transform array of objects to single object and map types in TypeScript
Transform array of objects to single object and map types in TypeScript

Time:09-29

Few days ago, I created a question about transforming of array of objects to single object and keep types. However, I provided simplified situation, which did not solve my problem.

I have two classes Int and Bool which are children of Arg class. There are also 2 factory functions createInt and createBool.

class Arg<Name extends string> {

    protected name: Name

    public constructor(name: Name) {
        this.name = name
    }

}

class Int<Name extends string> extends Arg<Name> {}
class Bool<Name extends string> extends Arg<Name> {}

const createInt = <Name extends string>(name: Name) => new Int<Name>(name)
const createBool = <Name extends string>(name: Name) => new Bool<Name>(name)

Now, I would like to define arguments (Int or Bool with specified name) in array and function, that accept maped types of arguments (Number for Int, Boolean for Bool) in argument.

const options = {
    args: [
        createInt('age'),
        createBool('isStudent')
    ],
    execute: args => {
        args.age.length // Should be error, property 'length' does not exist on type 'number'.
        args.name // Should be error, property 'name' does not exist in 'args'.
        args.isStudent // Ok, boolean.
    }
}

I found question about mapping types, but I don't know how to map this args array to args object, so for now, I have just string there and loosing info about argument types.

type Options = {
    args: Arg<string>[],
    execute: (args: Record<string, any>) => void
}

Is there any way to do this and keep argument types in execute function? Here is a demo.

CodePudding user response:

There are a few problems with your example code that are hindering you from finding a solution. We will fix each one in turn until we have working code.


First, your definition of Arg<N> only strongly types the name, corresponding to a property key. If you want the compiler (and indeed, your code) to know anything about the type of the value of the property, then you will need to extend the definition of Arg to include it. For example:

class Arg<N extends string, V> {
  public constructor(protected name: N, protected value: V) { }
}

Here we have added a second generic type parameter V to correspond to the type of the value. Also, since TypeScript's type system is structural and not nominal, it means that you can't just add a type parameter named V and expect it to constrain anything. If you want the compiler to consider Arg<N, V> to depend on N and V, it must depend structurally or weird things might happen. The easiest way to do this is to give Arg a property of type V. Maybe in your actual code an Arg<N, V> won't hold onto a value of type V, but it will presumably be doing something related to type V. Maybe a method like validate(candidate: V): boolean; or something. But for this example, I'm just giving it a property.

Anyway we can extend Int and Bool now:

class Int<N extends string> extends Arg<N, number> { }
class Bool<N extends string> extends Arg<N, boolean> { }

and the compiler will definitely understand that Int<N> represents a number property and Bool<N> represents a boolean property. This understanding will be crucial in having the rest of the code below function properly.

This also means we need to do something to your create functions:

const createInt = <N extends string>(name: N) => new Int<N>(name, 0)
const createBool = <N extends string>(name: N) => new Bool<N>(name, false)

Now, the next problem is that there is no specific type in TypeScript that corresponds exactly to your Options type. You can make something specific that accepts all valid values for options, but it will unfortunately accept invalid values also:

type TooWideOptions = {
  args: readonly Arg<string, any>[],
  execute: (args: never) => void
}

The problem is that for any given type of the args property, there is a particular associated object type for the callback parameter of the execute method, and TooWideOptions does not capture this constraint. And it is currently impossible to capture this constraint in TypeScript without making Options generic:

type Options<A extends readonly Arg<any, any>[]> = {
  args: readonly [...A],
  execute: (args: ArgArrayToObject<A>) => void
}

Now we have Options<A>, where A represents the type of the args property (an array of Arg elements), and where the callback parameter of the execute method is of type ArgArrayToObject<A>, which we need to define.


How can we do that? Here's a possible implementation:

type ArgArrayToObject<A extends readonly Arg<any, any>[]> = 
  { [T in A[number]
     as T extends Arg<infer N, any> ? N : never
  ]: T extends Arg<any, infer V> ? V : never } extends 
  infer O ? { [K in keyof O]: O[K] } : never;

This type iterates over all the elements of A and builds a mapped type whose key names are remapped. Each element is of type Arg<N, V> for some N and V, and we make N the property key type and V the value type. For example:

type Test = ArgArrayToObject<[
  Arg<"str", string>, 
  Arg<"num", number>, 
  Arg<"dat", Date>
]>;
/* type Test = {
    str: string;
    num: number;
    dat: Date;
} */

So now we have a generic Options<A>. If you could somehow represent an infinite union of all possible A types, then you could build the specific Options type you want. But this requires what is known as existentially quantified generics, which TypeScript does not directly support (see microsoft/TypeScript#14466 for a feature request).

Instead, we will have to make do with leaving Options<A> generic in the usual way, and so you'll have to pick a particular A for each value of type Options<A>. You could force the developers to manually annotate the exact type, including a specific type for A, but it's tedious and redundant:

const explicitOptions: Options<[Int<"age">, Bool<"isStudent">]> = {
  args: [
    createInt('age'),
    createBool('isStudent')
  ],
  execute: args => {
    /* (parameter) args: { age: number;  isStudent: boolean;  } */
    args.age.length // error, no 'length' on number
    args.name // error, no 'name' on args
    args.isStudent // okay
  }
}

This works how you want, but it's not fun to write out both Options<[Int<"age">, Bool<"isStudent">] and [createInt('age'), createBool('isStudent')].


The final way to deal with this is to introduce a generic helper function, to get the compiler to infer A for you:

const asOptions = <A extends readonly Arg<any, any>[]>(options: Options<A>) => options;

This function just returns its input, so it doesn't serve any purpose at runime. But at compile time, it constrains options to be of type Options<A> for some A that the compiler infers:

const options = asOptions({
  args: [
    createInt('age'),
    createBool('isStudent')
  ],
  execute: args => {
    /* (parameter) args: { age: number;  isStudent: boolean;  } */
    args.age.length // error, no 'length' on number
    args.name // error, no 'name' on args
    args.isStudent // okay
  }
});

You can verify that options is still of the same type as the previously manually annotated version without forcing you to write it out:

// const options: Options<[Int<"age">, Bool<"isStudent">]>

So that's it. Now we have working code that can represent and enforce the constraint between the args property and the execute method of a value of an Options-like type.

Playground link to code

CodePudding user response:

I guess this is what you are looking for

class Arg<Name extends string> {

    name: Name

    public constructor(name: Name) {
        this.name = name
    }

}

class Int<Name extends string> extends Arg<Name> {}
class Bool<Name extends string> extends Arg<Name> {}

const createInt = <Name extends string>(name: Name) => new Int<Name>(name)
const createBool = <Name extends string>(name: Name) => new Bool<Name>(name)

type Unpacked<T> = T extends (infer U)[] ? U : T;

type Options <T extends (Int<string> | Bool<string>)[], V extends Unpacked<T> = Unpacked<T>>= {
    args: T,
    execute: (args: Record<V['name'], V>) => void
}

const args = [
        createInt('age'),
        createBool('isStudent')
    ]

const options: Options<typeof args> = {
    args,
    execute: args => {
        args.age
        args.age.length // Should be error, property 'length' does not exist on type 'number'.
        args.name // Should be error, property 'name' does not exist in 'args'.
        args.isStudent // Ok, boolean.
        
    }
}

playground

  • Related