Search code examples
typescripttypescript-typingsunion-types

Typescript Union type - field type override


I have following sample code that behaviour is not clear to me.

type A = {
  field: string
}

type B = {
  field: number
}

//I expect A | B is equivalent to field: A["field"] | B["field"]

type AORB = A | B

type AORBField = {
  field: A["field"] | B["field"]
}

type BaseTypes = {
  field: number | string
}

//Toggle between AORB and AORBField
type AB = AORB;
//type AB = AORBField;
//type AB = BaseTypes

const isA = (t: AB): t is A => {
  return typeof t.field === "string"
}

const isB = (t: AB): t is B => {
  return typeof t.field === "number"
}

const test = (x: AB) => {}
const testA = (x: A) => {}
const testB = (x: B) => {}
const testString = (x: string) => {}
const testNumber = (x: number) => {}

const getField = () => {
  const val = Math.random();
  return Math.random() % 2 ? val.toString(): val
}

const getDummyObject = () => {
  const val = Math.random();
  return { field: Math.random() % 2 ? val.toString(): val }
}

//Why error?
const ab: AB = {
  //field: 1 //This will pass for AORB
  //field: "string" //This will pass for AORB
  field: getField() //This will pass for AORBFields
}

//Why error?
const abc: AB = getDummyObject();


if (isA(ab)){
  const a: A = ab;
  testString(a.field)
  testNumber(a.field) //Expected err
  testA(a)
  test(ab)
}
else
{
  const b: B = ab; //This will fail for AORBField and BaseTypes, but that is expected since else statement cannot figure out main type
  testString(b.field) //Expected err
  testNumber(b.field)
  testB(b)
  test(ab)
}

There is a typescript error on ab and abc assignment that is surprising to me. I expect following types are alike AORB = AORBField = BaseTypes at least in context of what we can assign to them. Obviously only AORB would correctly infer type on "If else" statement.

I've failed to find explanation in TS docs, could someone enlighten me here?


Solution

  • That isn't how a union of object types works, and for the most part it isn't how you'd want them to work (although your particular case may be an exception). The return type of your getDummyObject is { field: string | number } and you can't assign that to either A or B, so you can't assign that to the union A | B either: it can't satisfy either type of the union. The two types don't "mix" the way you seem to want them to.

    type A = {
      field: number
    }
    
    type B = {
      field: string
    }
    
    type AB = A | B
    
    // return type of getDummyObject
    type Neither = {
      field: string | number
    }
    
    type Test1 = Neither extends A ? A : never   // never
    type Test2 = Neither extends B ? B : never   // never
    type Test3 = Neither extends AB ? AB : never // never
    

    Which makes sense if you think about it, how could something that's neither an A nor a B be assigned to something that's either an A or a B?

    If you change the code in getDummyObject to be

    const getDummyObject = () => {
      const val = Math.random();
      return Math.random() % 2 ? 
        { field: val.toString() } : 
        { field: val };
    }
    

    then it works just fine

    const foo: AB = getDummyObject(); // no error
    

    Playground