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.
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.
}
}