Can TypeScript interfaces be identified as the interface they inherit from?

Viewed 1401

I have a TypeScript interface that is meant to be extended by many other interfaces. For example, call it Parent and say 2 interfaces extend Parent and are called ChildA and ChildB. In reality there are many more children inheriting from Parent.

interface Parent {
  sharedProp: string;
}

interface ChildA extends Parent {
  uniqueChildAProp: string;
}

interface ChildB extends Parent {
  uniqueChildBProp: string;
}

Is there a way I can have another interface Grandparent that is defined similar to as follows:

interface Grandparent {
  children: Parent[];
}

Where the elements in the children array can be ChildA or ChildB (or any other interface that extends Parent)?

This would be a valid Grandparent:

const concrete: Grandparent = {
  children: [
    {
      sharedProp: 'fooA',
      uniqueChildAProp: 'barA'
    },
    {
      sharedProp: 'foo',
      uniqueChildBProp: 'barB'
    }
  ]
}

I want to avoid unioning the types ChildA, ChildB, etc. since there are likely to be a lot of inheritors of Parent.

1 Answers

If you want to restrict Grandparent's children property to be an array of just known extensions of Parent, then a union is the only way. If, on the other hand, you wish to accept Parent and all structurally compatible extensions, even ones that haven't been declared yet... then you don't need to change the definition at all.

Your existing Grandparent definition already allows the elements of the children property to be a Parent or any subtype of Parent like ChildA or ChildB. The issue you might be running into is that, when you use an object literal like you did with concrete, the compiler performs excess property checking and complains about properties it doesn't expect:

const concrete: Grandparent = {
  children: [
    {
      sharedProp: 'fooA',
      uniqueChildAProp: 'barA' // error!
    },
    {
      sharedProp: 'foo',
      uniqueChildBProp: 'barB' // error!
    }
  ]
};

But that doesn't mean that you can't pass a ChildA or ChildB; it just means that you need to tell the compiler that you intend to have a ChildA or ChildB so that the properties are not unexpected:

const cA: ChildA = { sharedProp: 'fooA', uniqueChildAProp: 'barA' };
const cB: ChildB = { sharedProp: 'foo', uniqueChildBProp: 'barB' };
const concrete1: Grandparent = { children: [cA, cB] };

Or you can refactor in a number of other ways to appease the compiler and avoid excess property checking:

const children = [
  {
    sharedProp: 'fooA',
    uniqueChildAProp: 'barA'
  },
  {
    sharedProp: 'foo',
    uniqueChildBProp: 'barB'
  }
];
const concrete2: Grandparent = { children }; // okay

If you want to allow subtypes of Parent without complaining about extra properties in object literals, you might decide to use an index signature because object literals are implicitly compatible with appropriate index signatures. Like this:

interface AnyParent extends Parent {
  [k: string]: unknown;
}

interface Grandparent2 {
  children: AnyParent[];
}

const concrete3: Grandparent2 = {
  children: [
    {
      sharedProp: 'fooA',
      uniqueChildAProp: 'barA'
    },
    {
      sharedProp: 'foo',
      uniqueChildBProp: 'barB'
    }
  ]
}; // okay

That works for your example code. Be careful though; according to microsoft/TypeScript#15300, interface types are not implicitly compatible with index signatures, so using an explicit ChildA annotation will no longer work:

const childA: ChildA = { sharedProp: "", uniqueChildAProp: "" };
const oops: Grandparent2 = { children: [childA] }; // error!

So an index signature is really only advisable if you only care about object literals and type aliases and not interfaces. Therefore I'd probably suggest the first option and refactor, annotate, or assert that you are intentionally adding extra properties.


Okay, hope that helps; good luck!

Playground link to code

Related