Reputation: 979
Unfortunately, the great answers to question Pass implicit keys and values type relationship to TypeScript generic does not cover the problem in current question: in generateInputsAccessObject
, the target function of that question, we don't not use the subtype-dependent properties.
In fact, the above question has been solved without passing the implicit keys and values type relationship to TypeScript generic, which is the heading, but I suppose now we can't avoid it anymore.
Create the EntitySpecification
, the generic type of ProductSpecification
such as:
properties
's type must be non-indexed. It mean that TypeScript compiler must know what ID
and price
are exists, but other keys - does not exist.properties
must be iteratable (Object.entires("ProductSpecification.properties")
must work).ID
and price
could have different type, but when we call EntitySpecification.properties.ID
or EntitySpecification.properties.price
, TypeScript compiler must know which type.
export type Product = {
ID: string;
price: number;
};
const ProductSpecification: EntitySpecification<keyof Product> = {
name: "Product",
properties: {
ID: {
type: DataTypes.string,
emptyStringIsAllowed: false
},
price: {
type: DataTypes.number,
numberSet: NumbersSets.nonNegativeInteger
}
}
};
Here:
export enum DataTypes {
number = "NUMBER",
string = "STRING"
}
export enum NumbersSets {
naturalNumber = "NATURAL_NUMBER",
nonNegativeInteger = "NON_NEGATIVE_INTEGER",
negativeInteger = "NEGATIVE_INTEGER",
negativeIntegerOrZero = "NEGATIVE_INTEGER_OR_ZERO",
anyInteger = "ANY_INTEGER",
positiveDecimalFraction = "POSITIVE_DECIMAL_FRACTION",
negativeDecimalFraction = "NEGATIVE_DECIMAL_FRACTION",
decimalFractionOfAnySign = "DECIMAL_FRACTION_OF_ANY_SIGN",
anyRealNumber = "ANY_REAL_NUMBER"
}
export type StringSpecification = {
readonly type: DataTypes.string;
readonly emptyStringIsAllowed: boolean;
};
export type NumberSpecification = {
readonly type: DataTypes.number;
readonly numberSet: NumbersSets;
};
Off course, the EntitySpecification
does no know at advance the keys, however the keys count could be arbitrary large (not 2 as in current ProductSpecification
).
Below solution satisfies to first two targets:
export type EntitySpecification<Keys extends string> = {
readonly name: string;
readonly properties: { [key in Keys]: StringSpecification | NumberSpecification };
};
Here the conflict situation, the consequence of violation of third condition:
type StringValidationRules = {
emptyStringIsAllowed: boolean;
};
const ID_ValidationRules: StringValidationRules = {
emptyStringIsAllowed: ProductSpecification__EXPERIMENTAL.properties.ID.emptyStringIsAllowed;
}
Because TypeScript does not know that ID
has StringSpecification
type, we have below error:
TS2339: Property 'emptyStringIsAllowed' does not exist on type 'StringSpecification | NumberSpecification'. Property 'emptyStringIsAllowed' does not exist on type 'NumberSpecification'.
Upvotes: 0
Views: 80
Reputation: 329278
It looks like your EntitySpecification
is only mapping the property key types of an object type, whereas you need to map the corresponding value types also. In this case, it would be best if you pass the entire object type in and not just its keys. You can then map the property types via some mapping structure, while continuing to map the keys as you are doing now.
Here's one way to represent the necessary mapping structure:
type DataMapping =
{ type: number, specification: NumberSpecification }
| { type: string, specification: StringSpecification }
Note that I don't plan to use an actual value of type DataMapping
anywhere; it's just a type definition that the compiler will be able to use to connect a property type to a specification interface.
Now EntitySpecification
can be defined as follows:
type EntitySpecification<T extends Record<keyof T, DataMapping["type"]>> = {
readonly name: string;
readonly properties: { [K in keyof T]: Extract<DataMapping, { type: T[K] }>['specification'] };
};
Note that I am constraining T
to be an object type whose property types are mentioned in the type
property of DataMapping
. This will allow Product
whose property types are only number
and string
, but would disallow something like {oops: boolean}
because boolean
does not (currently) have a corresponding specification.
The properties
property is a mapped type in which the keys K
are the same as the keys of T
(as you were doing before) and whose values are translated from the corresponding property type from T
, namely T[K]
.
The specific property mapping is Extract<DataMapping, {type: T[K]}>['specification']
, using the Extract
utility type to select the member of the DataMapping
union whose type
property is T[K]
, and then returning the specification
property from that member.
Let's see how it works on Product
:
type Product = {
ID: string;
price: number;
};
type ProductSpecification = EntitySpecification<Product>;
/* type ProductSpecification = {
readonly name: string;
readonly properties: {
ID: StringSpecification;
price: NumberSpecification;
};
} */
That's what you wanted, I think. If you have a value p
of type ProductSpecification
, then p.properties.ID
must be of type StringSpecification
, and p.properties.price
must be of type NumberSpecification
. Hooray!
Upvotes: 2