Search code examples
typescript

Why is {} type assignable to object type?


Can someone please explain why Second type below is true?

type First = object extends {} ? true : false // true
type Second = {} extends object ? true : false // true

Knowing that {} is anything except null and undefined while object is any non-primitive type, I understand why First type is true. But I can't understand why Second type is also true


Solution

  • You're right that {} extends object should be false, because not every {} is an object. Primitives like string or number or boolean are assignable to {} (which accepts any non-nullish value) but not to object (which rejects primitives). So then why is it allowed?

    The authoritative answer is contained in a comment on microsoft/TypeScript#56205 by the TS team dev lead:

    Allowing {} to be used as object is an intentional compatibility hole since object didn't always exist, so people used {} as the next-best thing.

    So it's intentionally unsound so as not to break lots of real world code with the existence of object.

    More details can be found in microsoft/TypeScript#60582. If {} were not assignable to object then neither would {a: string}. And then something that accepts objects would suddenly reject most types and interfaces because maybe they're primitive? (Even though {a: string} can't be primitive.) So then maybe TypeScript would need to aggressively analyze every interface to see if it overlaps with a primitive and then allow those which do not overlap to be assignable to object, so {length: number, a: string} is assignable to object but {length: number} isn't? As mentioned in another comment by the TS team dev lead:

    The root problem is that the inconsistencies here can't be removed, they can just be moved. We say that this program is legal

    function foo(s: string) {
       return s.length;
    }
    

    But why is it legal? It's legal because the string type also includes properties from the global String type.

    Similarly, we allow you to write this program, for the same reason:

    function foo<T extends { length: number }>(x: T) {
      return x.length;
    }
    // Legal call
    foo("hello world");
    

    The proposal here implies breaking this consistency: You can no longer take some block of expressions and talk about them in a higher-order fashion if some of those expressions involve property access on primitives. Accessing the length of a string is now a fundamentally different operation than accessing the length of an array.

    This raises further problems, consider something like this

    const p = "foo";
    type X = (typeof p)["length"];
    
    type GetLength<T> = T extends { length: infer U } : U ? never;
    type Y = GetLength<typeof p>;
    

    Is the type X even legal? One argument says yes, because it's what you'd get if you wrote p.length. Another argument says no, because this should be basically equivalent to GetLength, which would now produce never instead.

    Every place we produce or ingest a property name, you now have to think about whether that property name exists as part of an object syntax or doesn't. e.g. what's keyof string?

    This also implicitly breaks common "branding" patterns like string & { isNormalized: true } since string & object is obviously never.

    I'd also just weigh in that making interface implicitly extend object but not type X = { } seems extremely inconsistent and, arguably, counterintuitive? If anything I'd choose the opposite. You can imagine making it legal to write interface Foo extends object { (and making that idiomatic in your codebase) but there's not as much syntactic clarity in having to jam in an & object into a type declaration. But either way it introduces "yet another thing you have to know", which isn't great from the perspective of keeping the language learnable.

    So the issue is that there's a weird unsoundness around {} and object, and that none of the proposed ways to handle it have worked without introducing other, weirder, unsoundnesses.