Takeshi Tokugawa YD
Takeshi Tokugawa YD

Reputation: 979

Narrow indexed type by passing keys and associated values types to generic

Differences from similar question

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.

Target

Create the EntitySpecification, the generic type of ProductSpecification such as:

  1. 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.
  2. properties must be iteratable (Object.entires("ProductSpecification.properties") must work).
  3. 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).

Best of me for now

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

Answers (1)

jcalz
jcalz

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!


Playground link to code

Upvotes: 2

Related Questions