Home > Software engineering >  Some questions from typescript union type
Some questions from typescript union type

Time:01-11

I understand that the union type in ts can allow variables to have multiple types.

type Test1 = never | any; 
// Any is the top-level type  
// Test1 = any
type Test2 = "123" | string;
// String is the collection of all strings
// Test2 = string

Type T=A | B, when A and B are combined When A ("broad type") contains B ("specific type"), A ("broad type") will be concluded.

Then when I apply the conclusion of "object union", I have questions For parent-child type.

interface A1 {
    name: string
}
interface A2 extends A1 {
    age: number
}

type Test3 = A2 | A1;
 
// According to the conclusion, A2 is broader than A1, Test3 = A2
// Not equal to A2
/* Here I explain that when the value corresponding to Test3 is {name: "??"}, 
the mandatory item age attribute type in A2 type is unsafe
/


// According to the above explanation, I try to add options to A2. Will the result be different?
type Test4 = A1 | Partial<A2>;
// but, Not equal to A2 ?


type TypeKeys = keyof Test3;
// Why do I get such a result when I try to get the key
// TypeKeys = "name"


There are also questions when the application function returns

const record: Test3 = {
    name: 'name',
    age: 20
} 

const record2: Test3 = {
    name: 'name'
}

// Finally, I use Test3 type for function return
const fn = (): Test3 => record;

const da = fn();
da.name
da.age // The type shown here is unsafe
// Property 'age' does not exist on type 'Test3'.
// Property 'age' does not exist on type 'A1' 

CodePudding user response:

When you use keyof Test3, TypeScript is trying to get the keys of the type Test3 which is A2 , so it gives you back the keys that are in A2 interface, which is "name" only.

you can solve this by using a type-guard, checking if the returned object is having the property of age,

const fn = (): Test3 => {
    if(record.hasOwnProperty('age')){
        return record;
    }
    return record2;
}

CodePudding user response:

There is a conceptual difference between Test2 and Test3:

  • "123" exists within the string set already, i.e. "123" is a subset of the string set. So this union can effectively be collapsed into the string set
  • A1 is not a subset of A2 and vice versa, although this may seem counterintuitive at first glance:
    • A1 is an object with a single property name: string
    • A2 is an object with two properties name: string and age: number
    • There is no object that can be defined that can satisfy both of these definitions at the same time, therefore when you write A1 | A2, the best the compiler can resolve to is that it could be either A1 or A2, but certainly not both.
    • Note: This property is actually very powerful and allows us to leverage things like discriminated unions

When you define record and record2, you are doing the following:

  • record and record2 are annotated as Test3, which is equivalent to A1 | A2.
  • You pass in an object of the shape of A2 to record, which the compiler is perfectly happy with as this is a valid A1 | A2. Importantly it is not that record becomes A2 under the hood, it is still A1 | A2
  • You pass in an object of the shape of A1 to record2, which the compiler is perfectly happy with as this is a valid A1 | A2.
  • I find it easier to visualise if you imagine the variables defined as let instead of const; as long as the variable is assigned with something that is of the shape of A1 or A2 during its lifetime, the compiler will remain happy (even if it starts as A2 it could be A1 in future etc)

When all is said and done, despite the contents of the objects in record and record2 obviously being A2 and A1 respectively to us, because of the annotation Test3 it is impossible for the compiler to deduce whether the underlying object is A1 or A2. All it can infer about Test3 is that regardless of the current value, it will have a name: string property. It cannot know whether the age: number property is present or missing as that would depend on knowledge about whether the object is A1 or A2.


A common solution to this problem is to "unpack" the type using a type guard, for example:

function isA2(record: Test3): record is A2 {
  return (record as A2).age !== undefined;
}

function someFn() {
  const someVariable: Test3 = { name: 'someName' };

  if (isA2(someVariable)) {
    someVariable // A2
    someVariable.name // valid
    someVariable.age // valid
  }
  else {
    someVariable // A1
    someVariable.name // valid
    someVariable.age // invalid
  }
}

This explicitly informs the compiler what the shape of the underlying type is using a runtime construct, so even if the variable's value were to change, it would still be able to guarantee type safety.


It should now hopefully make sense why the compiler did not accept accessing a property called age from a variable typed Test3 in your fn definition.

const record: Test3 = {
    name: 'name',
    age: 20
}
const fn = (): Test3 => record;
const da = fn();

da.name // valid, this property definitely exists
da.age // invalid, this property may or may not exist

The following alternatives would all be valid

const newRecord1: A2 = {
    name: 'name',
    age: 20
}
const fn1 = (): A2 => newRecord1;
const da1 = fn1();

da1.name // valid
da1.age // valid
const newRecord2: Test3 = {
    name: 'name',
    age: 20
}
const fn2 = (): Test3 => newRecord2;
const da2 = fn2();

if (isA2(da2)) {
  da2.name // valid
  da2.age // valid
}

https://tsplay.dev/w1AeGw

  • Related