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 thestring
set already, i.e."123"
is a subset of thestring
set. So this union can effectively be collapsed into thestring
setA1
is not a subset ofA2
and vice versa, although this may seem counterintuitive at first glance:A1
is an object with a single propertyname: string
A2
is an object with two propertiesname: string
andage: 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 eitherA1
orA2
, 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
andrecord2
are annotated asTest3
, which is equivalent toA1 | A2
.- You pass in an object of the shape of
A2
torecord
, which the compiler is perfectly happy with as this is a validA1 | A2
. Importantly it is not thatrecord
becomesA2
under the hood, it is stillA1 | A2
- You pass in an object of the shape of
A1
torecord2
, which the compiler is perfectly happy with as this is a validA1 | A2
. - I find it easier to visualise if you imagine the variables defined as
let
instead ofconst
; as long as the variable is assigned with something that is of the shape ofA1
orA2
during its lifetime, the compiler will remain happy (even if it starts asA2
it could beA1
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
}