Search code examples
typescript

Typescript Variable Type Doesn't Change Even With Strongly Typed Manipulations


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?


Solution

  • 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