Inside out type errors in TypeScript

In the good old C++, types are distinct based on just their names, so one could have two empty classes A and B and instances of each could not be mixed up in function calls expecting one type or another. A template class instantiated with two different types, such as X<A> and X<B>, would also yield two distinct template instantiations that could not be mixed up in function calls and assignments.

Nowadays, structural typing in TypeScript focuses on what the type contains, and the type name doesn't play as much of a role in assignments. This logic extends to type parameters, which only have meaning when they are referenced in specific ways in their contexts.

This arrangement makes it quite tricky to figure out some of the typing errors, which sometimes sound more like puzzles rather than meaningful errors.

It can be assigned, but...

Those who are used to C++ templates, are likely to run into TypeScript errors phrased similarly to the one in the example below.

Type 'Record<string, any> & MongoDoc<any>' is not assignable to type 'D'.

'Record<string, any> & MongoDoc<any>' is assignable to the constraint of type 'D', but 'D' could be instantiated with a different subtype of constraint 'MongoDoc<I>'.'

The error would typically be reported in contexts where some type parameter occurs only once in the method signature, such as a single function parameter or just in the return value.

Consider this hypothetical code that implements a method in a Mongo DB application service class that tries to find a single document in a database. The method is intended to be used against different collections, so it returns a generic type D with the _id field typed as a generic type I.

The Collection class in this example returns untyped documents from the database and is an oversimplification of the actual Mongo DB collection class that takes a type parameter for collection documents.

type MongoDoc<T = ObjectId> = {_id: T};

declare class Collection {
name: string;
constructor(name: string);
findOne(query: Record<string, any>) : Record<string, any> & MongoDoc<any> | null;
};

class MdbSvc {
findOne<D extends MongoDoc<I>, I = D["_id"]>(
colname: string,
query: Record<string, any>) : D
{
let doc: Record<string, any> & MongoDoc<any> | null =
new Collection(colname).findOne(query);

if(doc == null)
throw Error("Not found");

return doc; // ERROR: 'D' could be instantiated with a different subtype...
}
}

The error is reported in the findOne method, against the return statement, as shown above.

What made this error confusing for me was that the error is reported inside the method, where we know that D extends MongoDoc, so the intent was not to assign the document to some unspecified type derived from MongoDoc, but rather to return an instance of D that for each caller would be substituted with some concrete document type being tracked across each call.

Let's add a couple of concrete types and method calls to see where this error would be useful, if TypeScript could report it similarly to where C++ would - in method calls where it could not infer the generic type D from arguments.

Consider two document types describing a student and a room, as well as some code that loads documents with these shapes from the database.

type StudentDoc = MongoDoc & {
_id: ObjectId;
name: string;
};

type RoomDoc = MongoDoc & {
_id: ObjectId;
floor: number;
door: number;
};

let mdbsvc: MdbSvc = new MdbSvc();

let student: StudentDoc = mdbsvc.findOne("students", {name: "Bob"});
let room: StudentDoc = mdbsvc.findOne("rooms", {floor: 2, door: 5});

Notice on the last line that the type of the room document is incorrectly specified as StudentDoc instead of RoomDoc.

Surprisingly, TypeScript does not report this as an error, which seems to be happening because no type related to the return type was passed into this method, so TypeScript seems to interpret the return type as any, or maybe even ignores it altogether, because specifying never as the target type does not report any errors either.

So, when the error says that D inside the method may be instantiated from a different subtype of MongoDoc, the error actually refers to the possible instantiations made outside of this method.

In other words, the error says that D can only be inferred as the constraint type inside the method, and may be StudentDoc or RoomDoc in call assignments outside, which both are subtypes of MongoDoc that is used as a constraint in this method, and that these two types may be confused when the return value of this call is assigned to those target types.

This error may be reported not only against return statements, but wherever an assignment to a variable typed D is made within the method, like this:

        let x: D = doc;

This is because from the point of this assignment, for all intents and purposes, x is typed as D, which TypeScript cannot identify beyond the constraint type, but if returned without an error, it may be confused with types derived from the constraint type.

What's the fix?

TypeScript needs additional type references to figure out how types relate to one another, and in this example type D is only referenced in the generic return type, so TypeScript is unable to verify type D against any other parameter, and it falls back to the constraint type and warns that the return value may be incorrectly assigned where this method is called.

Let's add entity classes with type predicates for each of the document types. A type predicate returns true if the specified parameter has the referenced type and false otherwise.

class Student implements StudentDoc {    
_id: ObjectId;
name: string;

constructor(doc: StudentDoc)
{
this._id = doc._id; this.name = doc.name;
}

static is_entity(doc: Record<string, any> & MongoDoc) : doc is StudentDoc
{
return doc.name != undefined;
}
}

class Room implements RoomDoc {
_id: ObjectId;
floor: number;
door: number;

constructor(doc: RoomDoc)
{
this._id = doc._id; this.floor = doc.floor; this.door = doc.door;
}

static is_entity(doc: Record<string, any> & MongoDoc) : doc is RoomDoc
{
return doc.floor != undefined && doc.door != undefined;
}
}

Now we can restructure our Mongo DB service method as shown below, and adjust method calls to pass in an entity class in each call.

interface Entity<D extends MongoDoc<I>, T, I = D["_id"]> {
new (doc: D): T;
is_entity(doc: Record<string, any> & MongoDoc<I>) : doc is D;
}

class MdbSvc {
findOne<D extends MongoDoc<I>, T, I = D["_id"]>(
ctor: Entity<D, T, I>,
colname: string,
query: Record<string, any>) : D
{
let doc: Record<string, any> & MongoDoc<any> | null =
new Collection(colname).findOne(query);

if(doc == null)
throw Error("Not found");

if(!ctor.is_entity(doc))
throw Error("Unexpected document type");

return doc;
}
}

let mdbsvc: MdbSvc = new MdbSvc();

let student: StudentDoc = mdbsvc.findOne(Student, "students", {name: "Bob"});
let room: StudentDoc = mdbsvc.findOne(Room, "rooms", {floor: 2, door: 5});

Now, the error in the method is gone, and instead the last line with the incorrect StudentDoc type is reported.

Outside the method, TypeScript now knows the exact type of the inferred D type parameter in each call, so it can validate all arguments passed into the method and report errors where incorrect return value assignments are performed.

Inside the method, TypeScript can now verify the D type against the type predicate in the ctor parameter, so it can assume that the error will be reported where the method is called, even though it does not appear that TypeScript knows the actual type of D inside the method, unlike it would be with C++.

Side notes

This code may be further improved to take advantage of the entity constructor passed into the method, so an entity instance may be returned instead of a document, as shown below, and the return type can be changed to T:

        return new ctor(doc);

This will allow maintaining additional methods and data in entity classes, as opposed to using bare documents loaded from Mongo DB or trying to reshape them by walking their prototype chains.

Note, however, that documents and entity classes may still be mixed up for the same entity type, as long as entity classes implement document type for their entities. Removing implements from entity classes and carrying documents as data members, with appropriate getters and setters in entity classes, resolves this ambiguity, but requires more maintenance.

Final thoughts

What caught me by surprise with this error was that without additional type references, such as the type predicate in the example above, generic types fall back to the constraint types within methods, but are still considered to be the target types (or not evaluated at all) in the assignment where these methods are called, so the error is reported within these methods, but is actually referring to possible incorrect assignments outside.

It would be helpful if authors this error message would take a step back and rephrased it to include specific uses of return values that could result in failed assignments with mismatched types. Instead, the only official mention of this error I found is this one:

https://www.typescriptlang.org/docs/handbook/2/functions.html#working-with-constrained-values

, which is based on an incorrect assumption that some function is supposed to return the same instance of the value that was passed into the function as an argument. This type of assertion cannot be described via typing in any language and the actual error there is that the Type within the method will not match the broader target type in the assignment where the method is called.

It would be even more helpful if TypeScript had a specification, so errors could be verified against a well defined set of rules, as one would do with many other languages, rather than rely on a subjectively written TypeScript handbook and a bunch of scattered pages around it. Perhaps one day the TypeScript team will follow the Python Typing Team in how they consolidated typing PEPs into a single specification. One can only hope.

Comments: