How to specify the generic typing when instantiating a class which allows a value of type or a value of a derived type

Why does this TypeScript code...

interface Base {
    prop1: string
}

interface Derived extends Base {
    prop2: string
}

class Collection<T> {
  items: T[] = []
  add(item: T): T {
    this.items.push(item)
    return item
  }
}

const col = new Collection<Base>()
col.add({
    prop1: 'value1',
    prop2: 'value2'
})

// Note this work around, which demonstrates that this is perfectly workable without the typing
console.log(
  (
    col.add({
      prop1: 'value1',
      prop2: 'value2'
    } as any) as any
  )
  .prop2
)

(see here)

...produce this error...

Argument of type '{ prop1: string; prop2: string; }' is not assignable to parameter of type 'Base'.
  Object literal may only specify known properties, but 'prop2' does not exist in type 'Base'. Did you mean to write 'prop1'?

More specifically, how can I specify that I want to create an instance of Collection that accepts items of type Base and any and all derived interface types (but no other types) without specifying a union of all derived types?

Based on the responses so far, I would guess that this is not possible in the current (4.5.4) version of TypeScript.


Solution 1:

Side stepping entirely the "why" of your code example, I don't think this has anything to do with WeakMap or extended interfaces at all. So I'm going to vastly simplify this problem:

type A = { str: string }
const test1: A = { str: 'string', num: 123 }
// Type '{ str: string; num: number; }' is not assignable to type 'A'.
//  Object literal may only specify known properties, and 'num' does not exist in type 'A'.(2322)

Typescript makes a lot of judgement calls with its errors. And here its deciding this probably a mistake because you are directly assigning an object literal to a type that is missing some of those literals properties. This causes the type system to "forget" those extra properties, resulting in them being inaccessible.

However, this works fine:

type A = { str: string }
const test2Data = { str: 'string', num: 123 }
const test2: A = test2Data

This is because the test2Data is its own value and type now, and can be used independently. testData.num is of type number, but test2.num doesn't exist (according to the type).

Playground


The exact same rules apply here and this works without issue:

const map = new WeakMap<String, Base>()
const data = {
    prop1: 'value1',
    prop2: 'value2'
}
map.set('key1', data)

Playground


TL;DR: When you assign an object literal to a type that would immediately lose some of the properties in that literal, typescript assumes that's a mistake since you could never access those properties directly after the initial assignment.


If you really want to capture extra unknown properties, you should do so in a type safe way. Like say with generics:

function makeThing<T extends { str: string }>(data: T): T {
  return data
}

const thing = makeThing({ str: 'string', num: 123 })
thing.num // safe

Playground


Your question has quite different code in it than when I first answered.

You can "solve" this case by making the add() function generic.

class Collection<T> {
  items: T[] = []
  add<U extends T>(item: U): U {
    this.items.push(item)
    return item
  }
}

Now instead of add() accepting a T, it now accepts a T or a subtype of T. This tells Typescript that the extra properties are not being discarded, since they will be returned by the methods return type.

Testing it out:

const col = new Collection<Base>()
const addedItem = col.add({
    prop1: 'value1',
    prop2: 'value2'
})
addedItem.prop1 // works
addedItem.prop2 // works

col.items[0].prop1 // works
col.items[0].prop2 // type error
;(col.items[0] as Derived).prop2 // works

But, again, this isn't a great idea. Because if you didn't actually add prop2 because the add method doesn't require it, then you have no guard against assuming that the property exists.

Purposefully stuffing data into a type that lacks the properties for that data, only to fish it out later with unsafe casts seems like a bad plan to me.

Playground