Repository navigation
Unsafe array mutation allowed by loose generic constraints in item-creator functions #62666
Description
🔎 Search Terms
mutate array, covariance, covariant, invariance, invariant
🕗 Version & Regression Information
- This is the behavior in every version I tried, and I reviewed the FAQ for entries about covariance and invariance
⏯ Playground Link
💻 Code
I have a grid on which I want to create items by using a helper-function. This function should return a new version of the grid.
The type of the grid is as following:
type Grid = {
items: {
id: string;
name: string;
}[];
};In the create-item function, I thought generics would be a cool idea, so I went for this definition:
const createItem =
<
D extends {
id: string;
},
T extends {
items: D[];
},
>(
createNewItem: () => D,
) =>
(grid: T): T => {
return {
...grid,
items: [...grid.items, createNewItem()],
};
};But when invoking this function, I found a serious flaw in my types:
const createItemOnGrid = (): ((grid: Grid) => Grid) =>
createItem(() => ({
id: 'new_id',
}));🙁 Actual behavior
No error, but data that does not comply with the type when running the code.
🙂 Expected behavior
An error at compile time.
What gave me the expected result was to rewrite the function as following:
const createItem =
<
D extends {
id: string;
},
T extends {
items: D[];
},
>(
createNewItem: () => T["items"][number],
) =>
(grid: T): T => {
return {
...grid,
items: [...grid.items, createNewItem()],
};
};Note that I only changed the definition of createNewItem here.
But with the code provided first, I would like to get an error when adding the element to the array, because the types could be incompatible (as shown).
Additional information about the issue
Someone in discord pointed me to the terms "covariance" and "invariance", and I started reading https://stackoverflow.com/questions/8481301/covariance-invariance-and-contravariance-explained-in-plain-english.
When searching for something in this direction with regards to TypeScript, I came to https://stackoverflow.com/questions/60905518/why-are-typescript-arrays-covariant and further via #1394 to #48240, but I couldn't find a way of getting a helpful result.
I also saw this in the documentation https://www.typescriptlang.org/docs/handbook/2/generics.html#variance-annotations but couldn't see why my approach still should not error out.
Discord thread, where I raised this issue first: https://discord.com/channels/508357248330760243/1430830666865709096
Activity
RyanCavanaugh commentedon Oct 23, 2025
Can you clarify where and what the bug is in this sample? It doesn't manifest any errors running it so I'm not quite sure what you're getting at
jcalz commentedon Oct 23, 2025
I'd say their problem is
I assume they're tripping over the fact that generic object spread is typed as an intersection which goes badly when properties are overwritten. Which means the most basic repro here looks like
and it's a duplicate of #42690?
SimonSimCity commentedon Oct 24, 2025
Yes, that's my main problem.
Sure, I'll do my best:
When writing the function, I assumed, that
Dcould just be one specific type, means, that the returned value ofcreateNewItemmust fitT['item'][number], or the return-type of the function would be invalid when calling, becausecreateItemdoes not return the same type it gets as input if the return-value of thecreateNewItemfunction does not return something compatible withT['item'][number].This said, I thought, there could either be an error at the return-statement, because it's not ensured that the returned value and the expected return value are compatible - effectively making it a duplicate of #42690, or there could be an error when calling the function, because the argument given as function is not compatible with
T["items"][number]. The returned value of the function would (rather than it beingT) be something in the realm ofT & { items: D[] }but no longer just satisfiable byTThe latter is what happens if I remove the explicit typing of the
createItemfunction, which begs the question, why I otherwise do not get an error likeFor the last two paragraphs, I don't think it is a duplicate of #42690 nevertheless 😅
Btw: Even if I type it as such, it doesn't error - and I guess here is where I would expect the error:
jcalz commentedon Oct 24, 2025
I don't understand why those should error (although intersections of arrays are weird). You'll find that both
testFn1andtestFn2will reject an input like{ items: [{ id: "abc" }] }. If intersections-of-arrays were automatically converted to arrays-of-intersections (and they're not, see #41874) thenNewGridwould be exactly the same type asGrid. I don't seeNewGridtype itself as the real problem.NewGridshould definitely be assignable toGrid.The problem is that
{ ...grid, items: [...grid.items, createNewItem()], }does not actually result in aNewGrid, even though that's the inferred type ofcreateItem<{ id: string }, Grid>(() => ({ id: 'new_id' })). Spreading is not intersection unless there's no overlap in properties. As soon as you have overwritten properties, then spreading{...T, ...U}starts looking likeOmit<T, keyof U> & U(except that's not quite right for optional properties ofUand ugh). Intersections have the advantage of being simple, and good enough for lots of use cases. But when it goes wrong it really goes wrong. This still looks like #42690 to me, and unless we get something like #10727, I imagine it will persist.wgrea commentedon Oct 24, 2025
The generic signature implies type preservation, but spread breaks that promise. It’s not just a variance issue—it’s a trust issue between inferred return type and actual mutation.
Typing
createNewItemas() => Ddecouples it from the array’s true element type. That opens the door to structurally incompatible mutations—breaking the integrity ofT.If you mutate a generic array, your mutation source must be typed as
T["items"][number]—not just a compatible-looking subtype.Spread isn’t additive—it’s destructive. You’re not extending
T, you’re replacing parts of it. TypeScript treats the result asT, but it’s really a Frankenstein type.This feels like a missing constraint in the type system: array mutations inside generic structures should preserve element type integrity. Otherwise, the return type is unsound—and dangerously misleading.
RyanCavanaugh commentedon Oct 27, 2025
Why do people keep saying "mutation" here? Spread is not mutation
jcalz commentedon Oct 27, 2025
Maybe they mean it in the sense that
{...["a", "b", "c"]}is a mutant? /swgrea commentedon Oct 28, 2025
Fair point—spread isn’t mutation in the runtime sense.
I used “mutation” to describe the structural shift in type integrity: the array’s shape changes, but TypeScript still treats it as preserved.
It’s less about runtime mutation and more about type trust erosion—where the inferred return type promises safety, but the actual structure breaks it.
Happy to reframe if there’s a better term for that kind of type-level misalignment.
RyanCavanaugh commentedon Oct 28, 2025
The linked duplicate(s) are the real root cause here - a lack of a proper higher-order spread operator is why intersection is used instead, and intersection has some baked-in assumptions about linearity that get violated in this example.
This fundamentally violates a core assumption of intersection types -- that
T & Uis always assignable toT. This is why it's always a super bad idea to intersect two arrays (and why generic intersection is unfortunately so fraught).typescript-bot commentedon Oct 31, 2025
This issue has been marked as "Duplicate" and has seen no recent activity. It has been automatically closed for house-keeping purposes.