Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why do spreads on type-guarded types cause type checks to be skipped?

Question:

Why don't I receive a compile-time error when I forget to add nested fields to an object of type T, when constructing said object using an object spread?

Example:

interface User {
  userId: number;
  profile: {
    username: string
  }
}

function updateUsername(user: User): User {
  return {
    ...user,
    profile: {
      // Error (as expected)
    }
  }
}

function updateUsernameGeneric<T extends User>(user: T): T {
  return {
    ...user,
    profile: {
      // No error (unexpected... why?)
    }
  }
}

My own speculation on the answer:

All I can imagine is that TypeScript allows subtypes to remove properties of their supers, making it possible that for some subtype T of User, the profile property might not contain any properties. (If so, I was unaware TypeScript allowed you to do this...)


TypeScript Version 4.1.2

Playground

like image 823
Lawrence Wagerfield Avatar asked Mar 08 '21 23:03

Lawrence Wagerfield


1 Answers

This has all to do with how the spread is resolved with generic types (see PR) compared to normal types. If you write the resulting object to a variable, you will immediately notice the difference: for non-generic types, the merged type is inferred as:

{
    profile: {};
    userId: number;
}

which leads to this type being unassignable to the annotated return type User that has a required username subproperty. This is exactly what the compiler error TS 2322 is telling you:

Property 'username' is missing in type '{}' but required in type '{ username: string; }'

Now, the situation with generics is a bit different: the type is actually inferred as an intersection of a subtype of User and a { profile: {}; } type:

T & {
    userId: string;
    profile: {};
}

The compiler is ok with that as the intersection is an "extension" of the annotated return type containing all the properties as per the definition of the intersection.


Whether this a good behavior is debatable as you can do the following, and the compiler will be none the wiser:

function updateUsernameGeneric<T extends User>(user: T): T {
  const newUser = {
    ...user,
    userId: "234",
    profile: {
      // No error (unexpected... why?)
    }
  }

  return newUser;
}

updateUsernameGeneric({ profile: { username: "John" }, userId: 123 }).userId //a-ok, "number"

Since the return type is an intersection of the generic type parameter and the merged properties, you can de-annotate the return type and let TypeScript infer it. Incompatible property types will be correctly inferred as never:

function updateUsernameGenericFixed<T extends User>(user: T) {
  const newUser = {
    ...user,
    userId: "234",
    profile: {
      // No error (unexpected... why?)
    }
  }

  return newUser;
}

updateUsernameGenericFixed({ profile: { username: "John" }, userId: 123 }).userId //never

Playground

like image 76
Oleg Valter is with Ukraine Avatar answered Nov 15 '22 09:11

Oleg Valter is with Ukraine