1

Issue

I'd like to do CRUD stuff on some unknown items (any object with id: number).

For simplicity, let's say that for now we're deling with messages like this:

type Message =
    | { id: number; type: "success"; message: string }
    | { id: number; type: "error"; message: string; status: number };

let messages: Message[] = [{ id: 1, type: "success", message: "Ok" }];

Please notice that if type === "error, status has to be provided (can't be undeifned, since hanlder could use .toPrecision() or other number method).

Update function can look like this (just a basic demo):

function updateItem<T extends { id: number }>(
    items: T[],
    id: number,
    changes: Partial<T>
): T[] {
    return items.map((item) => {
        if (item.id === id) {
            return { ...item, ...changes };
        }

        return item;
    });
}

Let's test it:

messages = updateItem(messages, 1, { type: "error" });
console.log(messages);
// => [{ id: 1, type: "error", message: "Ok" }]

and just like that TypeScript allowed me to create invalid message. It has type === "error", but it doesn't have a status.

It would notify about if I would implement it like this:

function updateMessage(
    items: Message[],
    id: number,
    changes: Partial<Message>
): Message[] {
    return items.map((item) => {
        if (item.id === id) {
            return { ...item, ...changes }; // TS ERROR
        }

        return item;
    });
}

but as I mentioned it will be used used for generic items.

Questions

  • Why TS allows Partial ussage in generic, but not when a type is hard coded?
  • How I could implement it? Idealy it should be able to detect which properties are always optional, and which are required if some property is equal to something (in this example if we pass type: 'error' it would be reasonable to also require status since it may or may not be present in object). My current idea is to do Partial<DeepIntersection<T>>, but I got stuck at DeepIntersection part (TypeScript deep intersection of objects union)

EDIT - example of using Combine

Thanks to awesome sugestion provided by @jcalz here TypeScript deep intersection of objects union I was able to make version with hard coded types work:

function updateMessage(
    items: Message[],
    id: number,
    changes: Partial<Omit<Combine<Message>, "id">>
): Message[] {
    return items.map((item) => {
        if (item.id === id) {
            return { ...item, ...changes }; // No error 
        }

        return item;
    });
}

now function knows that it's only safe to update message because even though type is also a property shared by all Message types, changing it without changing other properties could lead to unexpected states. On top of that I also omited id but thats just one extra easy step.

So, in theory I could also add Combine to updateItem (generic one) to add that additional safety, and it would solve the issue. That still doesn't explain why TS doesn't know it's needed though.

GreenTea222
  • 195
  • 3
  • 11
  • Perhaps because `updateMessage` specifies a return type and `updateItem` doesn't. – dud3 Jul 31 '22 at 20:33
  • 1
    @dud3 my bad, forgot to include it here, but of course, I have tested it already. It doesn't have any effect. – GreenTea222 Jul 31 '22 at 21:32
  • 1
    The issue here is that generic spread is modeled as an intersection by the compiler. This is often a good approximation, but it fails pretty badly when there are overlapping conflicting properties between the things being spread. `{ ...item, ...changes }` is inferred as type `T & Partial`, which is assignable to `T`. But that type isn't accurate and hence your problem. Does that address your question fully? If so I can write up an answer. – jcalz Aug 01 '22 at 00:22
  • True, it does infer as `T & Partial`, guessing you already have a language server on your text editor, you can simply assign `{ ...item, ...changes }` to a variable and check the return type. – dud3 Aug 01 '22 at 14:27
  • " is inferred as type `T & Partial`, which is assignable to `T`", " Does that address your question fully". Thanks, now I understand the reason why it doesn't fail, but I still don't get why `T & Partial` it's assignable to `T` then. I guess that was true before discrimination unions were introduced, and it just stayed that way to not break codebases? – GreenTea222 Aug 01 '22 at 18:35
  • No matter what `X` and `Y` are, `X & Y` is assignable to `X` (and `Y`). That's what an intersection *is*. So if you really had a value of type `T & Partial`, then it would be and should be assignable to a variable of type `T`. The problem is that `{...item, ...changes}` is *not* a value of `T & Partial`, because the spread operator does not always produce intersections. If `x` is of type `X` and `y` is of type `Y`, then `{...x, ...y}` isn't necessarily `X & Y`. It's just that `X & Y` is often a good enough approximation. – jcalz Aug 01 '22 at 18:48
  • If you understand and want me to write up my answer, let me know (and please use "@jcalz" in your response, because I am not automatically notified when you comment) – jcalz Aug 01 '22 at 18:49
  • You can think of type intersection as interface extension. – dud3 Aug 01 '22 at 18:54
  • @jcalz thanks, a lot now I uderstand. I didn't know TS does approximations like this (comming from C++). I think the explanation why `{...x}` could be dangerous, maybe will be helpful for some people. I'd also love to know what do you think could replace `Partial` + `{...x}` to make things safer when we want to update something. – GreenTea222 Aug 01 '22 at 19:11
  • I will write up an answer when I get a chance. I don't know if I have time to explore what is "safer" than `Partial` and spread here, so you might want a new question. TypeScript doesn't have a fully sound type system, though, so sometimes it's not worth it to spend a *lot* of effort on type safety. – jcalz Aug 01 '22 at 19:35

1 Answers1

1

I'm going to address this question:

  • Why does TS allow Partial usage in a generic type, but not in a specific type?

TL;DR:

  • The compiler approximates generic spread with simple intersection types. This approximation is useful but unsound. Your code trips over this unsoundness, and the compiler thus allows it to run with no error, even though it's unsafe.

When item and changes are of the specific object types Message and Partial<Message>, the compiler can infer a fairly accurate type for the value {...item, ...changes}:

declare const item: Message;
declare const changes: Partial<Message>;
const spread = { ...item, ...changes };
/* const spread: {
    id: number;
    type: "success";
    message: string;
} | {
    id: number;
    type: "success" | "error";
    message: string;
    status?: number | undefined;
} | {
    id: number;
    type: "success" | "error";
    message: string;
    status: number;
} | {
    id: number;
    type: "error";
    message: string;
    status: number;
} */

Notice how the type of spread is no longer assignable to Message, because it's possible for the type of spread to have a "error" property without a status property:

type Possibility = Exclude<typeof spread, Message>
/* type Possibility = {
    id: number;
    type: "success" | "error";
    message: string;
    status?: number | undefined;
} */

That's why you get the expected error in the specific updateMessage() version. The compiler can see that the result of the spread is not safe to assign to Message.


On the other hand, when item and changes are of the generic types T and Partial<T>, the compiler does what it always does when faced with generic spread: it approximates the resulting type with an intersection:

const example = { ...item, a: 123 }
// const example: T & { a: number; }

const spread = { ...item, ...changes };
// const spread: T & Partial<T>

This simple approximation is often good enough. It's great if the spread doesn't end up overwriting any properties. In the example above, if item doesn't have an a property in it, then T & {a: number} is exactly right.

But if the spread does overwrite properties then the intersection can be inaccurate. That's what's happening with spread. The value {...item, ...changes} is assumed to have the type T & Partial<T>, but it has a very good chance of being something else. And the compiler misses this.

If you had a real T & Partial<T>, you'd be justified in returning it where a T is expected. A value of type X & Y is assignable to both X and Y (that's what an intersection means, and why & is the symbol), so a T & Partial<T> is a perfectly valid T. This is why there's no compiler error here.


This is one case where TypeScript's type system is unsound. This instance may or may not be considered a bug. Related issue microsoft/TypeScript#42690 is marked as a bug, but it's not obvious what can be done here instead.

It is indeed possible to start synthesizing somewhat more accurate types for generic spreads, see Typescript, merge object types? for one approach for giving types to the Object.assign() method which is similar to object spread.

But these accurate types are complicated, and the soundness improvement might not be worth the usability and performance cost. Perfect soundness is not one of TypeScript's Design Goals (see non-goal #3).


Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360