Movatterモバイル変換


[0]ホーム

URL:


HomeArticles

TypeScript: Union to intersection type

June 29, 2020

TypeScript

Recently, I had to convert a union type into an intersection type. Working on a helper typeUnionToIntersection<T> has taught me a ton of things on conditional types and strict function types, which I want to share with you.

I really like working with non-discriminated union types when I try to model a type where at least one property needs to be set, making all other properties optional. Like in this example:

typeFormat320={ urls:{ format320p:string}}
typeFormat480={ urls:{ format480p:string}}
typeFormat720={ urls:{ format720p:string}}
typeFormat1080={ urls:{ format1080p:string}}

typeVideo= BasicVideoData&(
Format320| Format480| Format720| Format1080
)

const video1: Video={
// ...
urls:{
format320p:'https://...'
}
}// ✅

const video2: Video={
// ...
urls:{
format320p:'https://...',
format480p:'https://...',
}
}// ✅

const video3: Video={
// ...
urls:{
format1080p:'https://...',
}
}// ✅

However, putting them in a union has some side-effects when you need e.g. all available keys:

// FormatKeys = never
typeFormatKeys=keyof Video["urls"]

// But I need a string representation of all possible
// Video formats here!
declarefunctionselectFormat(format: FormatKeys):void

In the example above,FormatKeys isnever, because there are no common, intersecting keys within this type. Since I don’t want to maintain extra types (that might be error-prone), I need to somehow transform the union of my video formats to anintersection of video formats. The intersection means that all keys need to be available, which allows thekeyof operator to create a union of all my formats.

So how do we do that? The answer can be found in the academic description ofconditional types that have been released with TypeScript 2.8. There is a lot of jargon, so let’s go over this piece by piece to make sense out of it.

The solution#

I’ll start by presenting the solution. If you don’t want to know how this works underneath, just see this as a TL/DR.

typeUnionToIntersection<T>=
(Textendsany?(x:T)=>any:never)extends
(x:inferR)=>any?R:never

Still here? Good! There isa lot to unpack here. There’s a conditional type nested within a conditional type, we use theinfer keyword and everything looks like it’s way too much work that does nothing at all. But it does, because there are a couple of key pieces TypeScript treats special. First, the naked type.

The naked type#

If you look at the first conditional withinUnionToIntersection<T>, you can see that we use the generic type argument as a naked type.

typeUnionToIntersection<T>=
(Textendsany?(x:T)=>any:never)//...

This means that we check ifT is in a sub-type condition without wrapping it in something.

typeNaked<T>=
Textends...// naked!

typeNotNaked<T>=
{ o:T}extends...// not naked!

Naked types in conditional types have a certain feature. IfT is a union, they run the conditional type for each constituent of the union. So with a naked type, a conditional of union types becomes a union of conditional types. For example:

typeWrapNaked<T>=
Textendsany?{ o:T}:never

typeFoo= WrapNaked<string|number|boolean>

// A naked type, so this equals to

typeFoo=
WrapNaked<string>|
WrapNaked<number>|
WrapNaked<boolean>

// equals to

typeFoo=
stringextendsany?{ o:string}:never|
numberextendsany?{ o:number}:never|
booleanextendsany?{ o:boolean}:never

typeFoo=
{ o:string}|{ o:number}|{ o:boolean}

As compared to the non-naked version:

typeWrapNaked<T>=
{ o:T}extendsany?{ o:T}:never

typeFoo= WrapNaked<string|number|boolean>

// A non Naked type, so this equals to

typeFoo=
{ o:string|number|boolean}extendsany?
{ o:string|number|boolean}:never

typeFoo=
{ o:string|number|boolean}

Subtle, but considerably different for complex types!

So, back in our example, we use the naked type and ask if it extendsany (which it always does,any is the allow-it-all top type).

typeUnionToIntersection<T>=
(Textendsany?(x:T)=>any:never)//...

Since this condition is always true, we wrap our generic type in a function, whereT is the type of the function’s parameter. But why are we doing that?

Contra-variant type positions#

This leads me to the second condition:

typeUnionToIntersection<T>=
(Textendsany?(x:T)=>any:never)extends
(x:inferR)=>any?R:never

As the first condition always yields true, meaning that we wrap our type in a function type, the other condition also always yields true. We are basically checking if the type we just created is a subtype of itself. But instead of passing throughT, we infer a new typeR, and return the inferred type.

So what we do is wrap, and unwrap typeT via a function type.

Doing this via function arguments brings the new inferred typeR in acontra-variant position. I will explaincontra-variance in a later post. For now, it’s important to know that it means that you can’t assign a sub-type to a super-type when dealing with function arguments.

For example, this works:

declarelet b:string
declarelet c:string|number

c= b// ✅

string is a sub-type ofstring | number, all elements ofstring appear instring | number, so we can assignb toc.c still behaves as we originally intended it. This isco-variance.

This on the other hand, won’t work:

typeFun<X>=(...args:X[])=>void

declarelet f: Fun<string>
declarelet g: Fun<string|number>

g= f// 💥 this cannot be assigned

And if you think about it, this is also clear. When assigningf tog, we suddenly can’t callg with numbers anymore! We miss part of the contract ofg. This iscontra-variance, and it effectively works like an intersection.

This is what happens when we put contra-variant positions in a conditional type: TypeScript creates anintersection out of it. Meaning that since weinfer from a function argument, TypeScript knows that we have to fulfill the complete contract. Creating an intersection of all constituents in the union.

Basically, union to intersection.

How the solution works#

Let’s run it through.

typeUnionToIntersection<T>=
(Textendsany?(x:T)=>any:never)extends
(x:inferR)=>any?R:never

typeIntersected= UnionToIntersection<Video["urls"]>

// equals to

typeIntersected= UnionToIntersection<
{ format320p:string}|
{ format480p:string}|
{ format720p:string}|
{ format1080p:string}
>

// we have a naked type, this means we can do
// a union of conditionals:

typeIntersected=
UnionToIntersection<{ format320p:string}>|
UnionToIntersection<{ format480p:string}>|
UnionToIntersection<{ format720p:string}>|
UnionToIntersection<{ format1080p:string}>

// expand it...

typeIntersected=
({ format320p:string}extendsany?
(x:{ format320p:string})=>any:never)extends
(x:inferR)=>any?R:never|
({ format480p:string}extendsany?
(x:{ format480p:string})=>any:never)extends
(x:inferR)=>any?R:never|
({ format720p:string}extendsany?
(x:{ format720p:string})=>any:never)extends
(x:inferR)=>any?R:never|
({ format1080p:string}extendsany?
(x:{ format1080p:string})=>any:never)extends
(x:inferR)=>any?R:never

// conditional one!

typeIntersected=
(x:{ format320p:string})=>anyextends
(x:inferR)=>any?R:never|
(x:{ format480p:string})=>anyextends
(x:inferR)=>any?R:never|
(x:{ format720p:string})=>anyextends
(x:inferR)=>any?R:never|
(x:{ format1080p:string})=>anyextends
(x:inferR)=>any?R:never

// conditional two!, inferring R!
typeIntersected=
{ format320p:string}|
{ format480p:string}|
{ format720p:string}|
{ format1080p:string}

// But wait! `R` is inferred from a contra-variant position
// I have to make an intersection, otherwise I lose type compatibility

typeIntersected=
{ format320p:string}&
{ format480p:string}&
{ format720p:string}&
{ format1080p:string}

And that’s what we have been looking for! So applied to our original example:

typeFormatKeys=keyof UnionToIntersection<Video["urls"]>

FormatKeys is now"format320p" | "format480p" | "format720p" | "format1080p". Whenever we add another format to the original union, theFormatKeys type gets updated automatically. Maintain once, use everywhere.

Further reading#

I came to this solution after digging into whatcontra-variant positions are and what they mean in TypeScript. Next to type system jargon, it tells us effectively that we need to provide all constituents of a generic union if used as a function argument. And this works as an intersection during the assignment.

If you want to read more on this subject, I suggest catching up on the following articles.

Related Articles

oida.dev © 2012 - 2025

[8]ページ先頭

©2009-2025 Movatter.jp