Example TS Playground
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?
5
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.
Playground link to code