0

Based on the following structure:

interface ApiEntity {
  [key: string]: number | string;
}

interface ApiData {
  [key: string]: ApiEntity;
}

interface Entity1 extends ApiEntity {
  id: number;
  name: string;
}

interface Entity2 extends ApiEntity {
  uuid: string;
  description: string;
}

interface MyData extends ApiData {
  a: Entity1;
  b: Entity2;
}

How can I create an interface that would accept only a valid entity and property:

// The problem
interface DataFields<T extends ApiData> {
  label: string;
  entity: keyof T; // ensure that entity is one of the properites of abstract ApiData
  property: keyof keyof T; // ensure that property is one of the properties of ApiEntity
  other?: string;
}

So the created fields are safe and TS shows errors when invalid:

const fields: MyDataFields<MyData>[] = [{
  label: 'A ID',
  entity: 'a', // valid
  property: 'id', // valid
},{
  label: 'B Description',
  entity: 'b', // valid
  property: 'description', // valid
},{
  label: 'Invalid',
  entity: 'c', // TS Error
  property: 'name', // TS Error
}];

Or even better:

const MyDataFields: DataField<MyData>[] = [
  {label: 'A ID', entityProperty: 'a.id'},
  {label: 'B Description', entityProperty: 'b.description'},
  {label: 'Invalid', entityProperty: 'c.name'}, // TS Error
];
1
  • ApiData is declared to allow any key as a string, and MyData extends ApiData, so how can c be inferred as an invalid property name? The interface says every string is a valid property name. The same applies for Entity1 and Entity which extend ApiEntity, where the latter says every string is a valid property name. Commented Nov 24, 2019 at 21:03

1 Answer 1

1

With the interface hierarchy you've defined, where ApiData and ApiEntity are declared to allow any string as a property name, there is simply no way for Typescript to infer that c is not a valid property name for MyData or that name is not a valid property name for Entity2. On the contrary, Typescript will infer that these are valid property names because of how the interfaces are declared:

function foo(obj: Entity1): void {
  // no type error
  console.log(obj.foo);
}
function bar(obj: MyData): void {
  // no type error
  console.log(obj.bar);
}

But this problem can be solved if you get rid of ApiData and ApiEntity, or at least narrow them to not allow all strings as property names.

The valid values for property depend on the value of entity, so this needs to be a discriminated union type where entity is the discriminant. We can construct it using a mapped type:

interface Entity1 {
  id: number;
  name: string;
}

interface Entity2 {
  uuid: string;
  description: string;
}

interface MyData {
  a: Entity1;
  b: Entity2;
}

type DataFields<T> = {
  [K in keyof T]: {
    label: string,
    entity: K,
    property: keyof (T[K])
  }
}[keyof T]

Examples:

const fields: DataFields<MyData>[] = [{
  label: 'A ID',
  entity: 'a', // OK
  property: 'id', // OK
}, {
  label: 'B Description',
  entity: 'b', // OK
  property: 'description', // OK
}, {
  label: 'Invalid',
  // Type error: 'c' is not assignable to 'a' | 'b'
  entity: 'c',
  property: 'name',
}, {
  label: 'Invalid',
  entity: 'a',
  // Type error: 'foo' is not assignable to 'id' | 'name' | 'uuid' | 'description'
  property: 'foo',
},
// Type error: 'id' is not assignable to 'uuid' | 'description'
{
  label: 'Invalid',
  entity: 'b',
  property: 'id',
}];

Playground Link

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.