typescriptpartial-classes

TypeScript Recursive Partial confused when Classes share a property name


I've been using a Conditional Type in TypeScript to create a Recursive Partial. The issue I'm having is hard to explain without an example, so please see the code below (note: my particular use case is more complex, but the example illustrates the issue):

// The RecursivePartial type used in the illustration
type RecursivePartial<T> = {
    [P in keyof T]?:
      T[P] extends (infer U)[] ? RecursivePartial<U>[] :
      T[P] extends object ? RecursivePartial<T[P]> :
      T[P];
};


// A class we will use -- note it has a string property called "name"
// and a numeric property called "commonProperty"
export class Thingy1 {
    public constructor(public name: string) {}
    public commonProperty = 1;
}

// Another class we will use -- it has two properties that
// Thingy1 does not have (label and value) but it also has a
// numeric property called "commonProperty" just like Thingy1
export class Thingy2 {
    public constructor(public label: string, public value: number) {}
    public commonProperty = 2;
}

// A class with a property of type Thingy1
export class Thingy3 {
    public constructor(public thingy1: Thingy1) {}
}

// Define a RecursivePartial of Thingy3
const partialOfThingy3: RecursivePartial<Thingy3> = {};
// The following line illustrates the problem!  I set the Thingy1-type
// property to an instance of Thingy2, and TypeScript seems perfectly
// find with it!
// HOWEVER, if I change the commonProperty name (or remove it) from
// either the Thingy1 or Thingy2 class, TypeScript complains as expected
partialOfThingy3.thingy1 = new Thingy2("hi", 2); // This is allowed!

As noted in the comments, TypeScript allows us to set a property in the RecursivePartial to an object of a different type than the property in the original class. This oddity happens only when the "different type" shares at least one property with the original class.

Is there a way to fix this behavior so that the Conditional Type will not allow a property to be set to an object of an incorrect type just because that type happens to share a property with the correct type?

Simplification thanks to kikon:

The following simplifies the example down to the root of the issue:

type RecursivePartialThingy3 = {
    thingy1?: {
        commonProperty?: number;
        name?: string;
    }
}

const partialOfThingy3: RecursivePartialThingy3 = {};
const thingy2 = {commonProperty: 2, label: "hi"};

// This is allowed:
partialOfThingy3.thingy1 = thingy2; 

// And yet, this is NOT allowed:
partialOfThingy3.thingy1 = {commonProperty: 2, label: "hi"};

In the second case, TypeScript complains that "Object literal may only specify known properties, and 'label' does not exist in type '{ commonProperty?: number | undefined; name?: string | undefined; }'"

So why do we have two different requirements for type checking in these two cases? Is it possible to tell TypeScript to issue a warning or error in the first case as it does in the second case?

Thanks!


Solution

  • If we simplify your code and expand the templates we'll see that this is the normal behavior of Typescript compatibility check:

    type RecursivePartialThingy3 = {
        thingy1?: {
            commonProperty?: number;
            name?: string;
        }
    }
    
    const partialOfThingy3: RecursivePartialThingy3 = {};
    const thingy2 = {commonProperty: 2, label: "hi", value: 2};
    partialOfThingy3.thingy1 = thingy2; // This is allowed!
    

    The assignment to property thingy1 is checked against the weak type Thingy1 using weak type detection so it's required to have at least one common property -- this is the reason you get the type ... has no common property with type ... error when you change the name of commonProperty.

    On the additional part of the question

    (for the record, initially a comment)

    In TypeScript assigning an object literal is treated differently than assigning a variable to the same property. If the rhs value is a literal, it applies excess property checks.

    This is not related to weak types, as one can see from this example:

    type Thingy1 = {
      name : string
    };
    let thingy1: Thingy1;
    
    const thingy2 = {name: "I", label: "a"};
    thingy1 = thingy2;  // OK, regular compatibility rules of "structural typing"
    thingy1 = {name: "I", label: "a"}; // ERROR: excess property checks
    

    While one should treat this as a given (albeit funny) fact of TypeScript, and not analyze it much further, the only reasonable justification I found for it is that the compiler is concerned with mistyped properties:

    type Thingy1 = {
      name : string;
      label?: string
    };
    let thingy1: Thingy1 = {name: "I", lbel: "a"}; // mistyped property "label"? 
    

    With the object literal assignment, the object literal only appears there, while if a variable is used, the typing error should be detected at the definition of that variable (and possibly with other uses of the same variable).