Example Code
type Base = {
name: string
}
type ExtA = {
address: string
}
type ExtB = {
streetName: string;
}
function handler<T extends Base>(input: T): T {return input}
/** how do I build an object up? */
let payload = {
name: "Aaron"
}
const addA = <T extends Base>(payload: T): T & ExtA => {
return {
...payload,
type: "a",
address: "123 Fake St"
}
}
const addB = <T extends Base>(payload: T): T & ExtB => {
return {
...payload,
type: "b",
streetName: "Main"
}
}
payload = addA(payload)
payload = addB(payload)
// type should be Base & ExtA & ExtB but it isn't
const wrongType = handler(payload)
// ^?
I'm expecting payload
to change type as it passes through my manipulating functions addA
and addB
but it isn't. How do I get TS to understand that the type of this variable should be changing?
TypeScript doesn't model arbitrary mutation of state of variables. And while there is some support for narrowing variables upon assignment, that only happens if the type of the variable is a union type. So if you reassign payload
of type X | Y
with a value of type X
, then payload
will narrow to X
. But if you reassign payload
of type X
with a value of type X & Y
, no narrowing happens.
If you want to play nicely with the type system, you should let each variable have a single type for its lifetime. So instead of reassigning payload
, you could just have new variables for each assignment:
const payload = {
name: "Aaron"
}
const payload1 = addA(payload)
const payload2 = addB(payload1)
const result = handler(payload2)
// ^? const result: { name: string; } & ExtA & ExtB
If you really want to represent a single variable whose type gets narrower over time, you could do it with assertion functions, but those have lots of caveats. They only work by narrowing the variable/property passed in as an argument, and they can't return any defined values, so reassignment still wouldn't narrow as you intended. It would have to look like this:
function addA<T extends Base>(payload: T): asserts payload is T & ExtA {
Object.assign(payload, {
address: "123 Fake St"
});
}
function addB<T extends Base>(payload: T): asserts payload is T & ExtB {
Object.assign(payload, {
streetName: "Main"
});
}
Here addA()
and addB()
are assertion functions that actually modify their inputs (because Object.assign()
mutates its first argument) instead of returning anything.
Now you can have a single payload
variable, and each call to the assertion functions will narrow it:
const payload = {
name: "Aaron"
}
addA(payload)
addB(payload)
const result = handler(payload)
// ^? const result: { name: string; } & ExtA & ExtB
This works, but I would tend to avoid it unless you really need it. If you ever decide that you need something like a removeA()
to widen the input, then assertion functions can't work, since it's not a narrowing. Then you'd have to use different variables.