Click here to Skip to main content
15,849,829 members
Articles / Programming Languages / Typescript

Type System Features

Rate me:
Please Sign up or sign in to vote.
0.00/5 (No votes)
3 Sep 2021CPOL16 min read 3.3K   26   3  
An introduction to powerful type system features.
The Typescript type system is much more powerful than it may seem on the surface. This article provides an introduction to some of its advanced features along with some basics to showcase how flexible and creative you can be with the type system.

Two examples that use many of the features in the article to increment a number within the type system:

Contents

Introduction

Basic Types

Features

Extends

Inferred Type Variables

Immediately Indexed Object Definitions

Variance

Function Unions and Intersections

Decomposition

Mapped Types

Templated String Literals

Deferred Type Resolution

References

History

Introduction

I've heard developers refer to Typescript as "Javascript with types" and was guilty of the same at first. While that may be somewhat true broadly speaking, the type system you get is much more feature-rich than it seems on the surface. It offers basic logic constructs, object transforms, and a host of possibly non-intentional tricks that let you be much more expressive than most are probably accustomed to coming from the majority of popular languages.

Basic Types

At a glance, the Typescript type system looks something like the following with respect to basic types:

Image 1

It may seem complex but if you stare at it for a bit it makes a lot of sense. The relationships are read as "undefined extends void" or equivalently "undefined is assignable to void." A quick summary:

  • any is the universal type. It is a top type, bottom type, and everything in between.
  • unknown is the top type. Everything is assignable to unknown.
  • never is the bottom type. never is assignable to everything.
  • literals extend their non-literal counterparts (e.g. true extends boolean, 3 extends number).
  • complex basic types extend object (e.g. function, tuple, enum)

The different line-only arrows are for the cases where there is some notable exception such as assignability between arrays and tuples. Keep in mind that while these may seem like inheritance relationships, that's not necessarily the case since Typescript uses a structural type system.

TypeScript
class A { A: string; } class B { A: string; }
var a: A extends B ? true : false; //true

The type of a is true despite the fact A and B are hierarchically unrelated. This is because they have the same structure. It's much easier in my opinion to think in terms of sets:

Image 2

TypeScript
class A { A: string; }
class B { A: string; }
class C { A: number; }

class D { A: number; B: string; }
//or equivalently
class D extends C { B: string; }

From the set perspective, types can be thought of as named groupings of properties from the universal set U of all possible properties where a property is a name associated with a type. Therefore A: number and A: string are different properties as well as A: string and B: string. A type extends another type if it is a superset of the other type. So in the example above, D extends C. If the superset relationship isn't a proper superset then both types extend each other - A extends B and B extends A. This is because they are effectively different names for the same type since they have exactly the same set of properties.

However it ends up making sense, the important part is that it makes sense because the rest of the features require understanding how types relate to one another.

Features

A extends B ? True-branch : False-branch

This is equivalent to a ternary if with the condition being assignment compatibility from A to B. Furthermore in the true-branch, A's type has the additional type constraint of B similar to how type guards work. If A is a naked type parameter (just a lone type variable) and is a union, that union is distributed over the extends and the results are unioned together. For example:

TypeScript
class A { A: string; } 
class B extends A { B: string; } 
class C extends B { C: string; }

type example<T> = T extends B ? true : false;
var a: example<A|C>; //a has the type boolean (i.e. true | false)

The above happens because after distribution we get (A extends B ? true : false)|(C extends B ? true : false) which simplifies to false | true. Note that this doesn't happen in the case of:

TypeScript
var b: A|C extends B ? true : false; //b has the type false.

Since we no longer have a type parameter, no distribution occurs. Also consider the case of:

TypeScript
type example2<T> = [T] extends [B] ? true : false;
var c: example2<A|C>; //c has the type false.
var d: example2<C>; //d has the type true.

The type parameter is no longer naked since we are using it in a tuple type ([T]) so no distribution occurs.

Inferred Type Variables

Inferred type variables are temporary type variables similar to type parameters but bound to the scope of the true-branch and are automatically assigned by context. Their types are as narrow/specific as can be safely inferred (otherwise all inferences would trivially resolve to any or unknown).

TypeScript
type example<T> = T extends infer U ? U : never;
var a: example<boolean>; //boolean

A more complicated example:

TypeScript
type example<T extends [...any]> = T extends [...infer _, infer A] ? A : T;
var a: example<[boolean, null, undefined, number]>; //number
var b: example<[void]>; //void
var c: example<[]>; //unknown

This uses inferred type variables to return the last type in a tuple. Notice that what we're essentially saying here is "can we successfully infer types for _ and A from T that make sense with the given signature?" The signature of the inference statement can have important repercussions for the resulting variable's type:

TypeScript
type example<T extends [...any]> = T extends (infer A)[] ? A : never;
var a: example<[string, number]>; //string|number

type example2<T extends [...any]> = T extends [...infer A] ? A : never;
var b: example2<[string, number]>; //[string, number]

The key difference between these is that (infer A)[] is a variable-length array signature and [...infer A] is a fixed-length array signature (i.e. a tuple). This shows how the resulting type information is widened into a union in the first example since it is inferred from a variable-length array signature despite the input being the more narrow tuple type. This loss of positional type information occurs because a variable-length array signature has no concept of position - only of what types are valid for all (not each) of its elements.

Immediately Indexed Object Definitions (i.e. switch statements for types)

One limitation of extends is it doesn't cleanly allow switch-style branching. We can get around this by using the fact that if you index an object definition, what you get back is the associated property's type which effectively turns the object definition into a switch statement:

TypeScript
type PickOne<Choice extends 0 | 1 | 2 | 3> =
    {
        0: 'Uhhh...',
        1: 'Odd',
        2: 'Even',
        3: 'Odd'
    } [Choice];

Remember, literals are a type! If we were just using extends this would look like:

TypeScript
type PickOne<Choice extends 0 | 1 | 2 | 3> =
    Choice extends 0 ?
        'Uhhh...'
        : Choice extends 1 ?
            'Odd'
            : Choice extends 2 ?
                'Even'
                : Choice extends 3 ?
                    'Odd'
                    : never;

Kinda messy, right? The object indexer also supports any type expression that results in a valid index - not just simple type parameters:

TypeScript
type Nullable<T> =
    {
        0: T | null,
        1: T
    } [T extends string | object ? 0 : 1];

A bit of a silly example but it showcases the point. Another neat feature is support of recursion:

TypeScript
type digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type DigitsAfter<X extends digit, Current = never> =
    {
        0: DigitsAfter<1, 1>,
        1: DigitsAfter<2, Current | 2>,
        2: DigitsAfter<3, Current | 3>,
        3: DigitsAfter<4, Current | 4>,
        4: DigitsAfter<5, Current | 5>,
        5: DigitsAfter<6, Current | 6>,
        6: DigitsAfter<7, Current | 7>,
        7: DigitsAfter<8, Current | 8>,
        8: Current | 9,
        9: never
    } [X];

Recursion works as long as it can't infinitely recurse. There'll be either an error when using the type or on the recursive property that's causing the issue if infinite recursion is possible. Sometimes this can be a bit finnicky with what the type system accepts but it tends to work best if you have a clear base case and shallow recursion.

Variance

Variance isn't really different in Typescript per se. The standard stuff expected is there:

class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }

let f1: ()=>A = () => new B(); //Covariant with respect to return types
let f2: (_: B)=>void = (_: A) => {}; //Contravariant with respect to parameter types
let f3: (_: B)=>B = (_: A) => new C(); //Mixed variance

let f4: (_:B)=>void = (_:C) => {}; //Wait... what? Bivariant parameter type?

There's one big addition to the standard stuff if the "strictFunctionTypes":false tsconfig option is set - function parameter types are bivariant. Not particularly useful in my experience outside of explicitly filtering things in a bivariant function context but good to be aware of in case a need arises.

TypeScript
//Note: requires '"strictFunctionTypes": false' to be set in tsconfig
class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }
class D { D: string; } 
class E extends D { E: string; }

type GetTypesRelatedTo<T, options> =
    T extends any ? //distribute T if it's a union to handle each type separately
        options extends any ? //distribute options
            //compare each option to T in a bivariant context
            ((_:options)=>void) extends ((_:T)=>void) ?
                options
                : never
            : never
        : never;

let test: GetTypesRelatedTo<B, A | B | C | D | E>; //returns B | A | C

Function Unions and Intersections

Unions and intersections behave very similar to base and derivative classes, respectively. Much like inheritance, the properties of assignability and functionality operate opposite of one another. "Up" towards more base types yields greater assignability but lower functionality while moving "down" towards more derivative types yields lower assignability but greater functionality.

Image 3

From this perspective, a union is a base type relative to its constituent types and an intersection is a derivative type relative to its constituent types. We can verify this analogy holds:

TypeScript
class A { A: string; }
class B { B: string; }
class AB { A: string; B: string; }

let x: A | B;
//Testing assignability
x = new A();
x = new B();
x = new AB();
//Testing functionality
x.A;          //Error, property A does not exist on type A | B
x.B;          //Error, property B does not exist on type A | B

let y: A & B;
//Testing assignability
y = new A();  //Error, type A is not assignable to type A & B
y = new B();  //Error, type B is not assignable to type A & B
y = new AB();
//Testing functionality
y.A;
y.B;

This analogy is especially convenient for discussing unions and intersections of functions. For example, try to fill in the "???" when coercing the following into T=>U form:

TypeScript
(a: ???)=>??? = ((a: string)=>string) | ((a: number)=>number)

(a: ???)=>??? = ((a: string)=>string) & ((a: number)=>number)

To solve this, first let's determine what the returns would be since that's a simple starting point:

  1. A union of functions would naturally have a return type that is the union of the function return types.
  2. An intersection of functions would naturally have a return type that is the intersection of the function return types.

This leads to a somewhat surprising conclusion when considering assignment; the coercion into a T=>U form doesn't result in something that is strictly equivalent. Instead, coercion of a function union creates a baser type while coercion of a function intersection creates a more derivative type.

TypeScript
//If this was reversed, (string | number) wouldn't be assignable to string due to possibility
//of getting a number, and it wouldn't be assignable to number due to the possibility of getting
//a string.
(a: ???)=>(string | number) = ((a: string)=>string) | ((a: number)=>number)

//If this was reversed, string wouldn't be assignable to (string & number) due to not having
//the properties of number, and number wouldn't be assignable to it either due to not having
//the properties of string.
((a: string)=>string) & ((a: number)=>number) = (a: ???)=>(string & number)

Side note: Despite only talking about assignability between types, it's important to remember that a type's purpose is to describe a concrete representation. This fact has to be considered when determining what should be compatible. While the union example is relatively intuitive, it may have been a surprise that we consider individual assignment even in the intersection example. This is because practically speaking a function intersection represents function overloading at the concrete level and therefore the concrete result is only from a single function. This is why string=>string & number=>number is not assignable to ???=>(string & number). Only one of those values would actually be returned and it's return would not satisfy the more derivative (string & number). The following is an example of these properties:

TypeScript
class Overload {
    static test(a: string): string;
    static test(a: number): number;
    static test(a: string | number) { return a; }
}
//Behavior of the function overload
let overloadTest1 = Overload.test("a"); //variable type is string
let overloadTest2 = Overload.test(5); //variable type is number

//Behavior of the function intersection (concretely backed by the function overload)
let overloadFunc: ((a:string)=>string) & ((a:number)=>number) = Overload.test;
let overloadTest3 = overloadFunc("a"); //same result as overloadTest1
let overloadTest4 = overloadFunc(5); //same result as overloadTest2

Now we need to figure out the argument types for the original example. For the union, we want something more derivative (contravariance of the arguments) so we'll go with string & number since that is assignable to either string or number. For the intersection, we want something less derivative since we're working on the opposite side of the assignment now so we'll go with string | number because both string and number are assignable to it.

TypeScript
(a: string & number)=>(string | number) = ((a: string)=>string) | ((a: number)=>number)

((a: string)=>string) & ((a: number)=>number) = (a: string | number)=>(string & number)

In summary, coercing an intersection of functions into T=>U form creates a more derivative type while doing the same for a union of functions creates a baser type. The following demonstrates everything we just figured out:

TypeScript
class W { w: string; }
class U { u: string; }

declare const Inter: ((a: W)=>W) & ((a:U)=>U);
declare const TU_Inter: (a: W | U)=>(W & U);
let ex1: typeof Inter = TU_Inter;
let ex2: typeof TU_Inter = Inter; //Error

declare const Union: ((a: W)=>W) | ((a:U)=>U);
declare const TU_Union: (a: W & U)=>(W | U);
let ex3: typeof Union = TU_Union; //Error
let ex4: typeof TU_Union = Union;

In the following example, we'll step through the code explaining how we use the properties of function unions and intersections to extract the last element (see note at the end of the section) from a union; something that on the surface seems impossible without already knowing the element type in order to discriminate the union.

TypeScript
type UnionToFunctionIntersection<T> =
    (T extends any ? () => T : never) extends infer U ?
        //Function union coerced into the form V=>void.
        (U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
            V
            : never
        : never;
//Function intersection coerced into the form ()=>U.
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;

Let's break it down from the top.

TypeScript
type UnionToFunctionIntersection<T> = 
    (T extends any ? () => T : never) extends infer U ?

What we're doing here is distributing T in case it's a union, then returning a union of functions with each element of T in a return-type position. We're inferring the result of this as U so we have a simple type variable to refer to later. It also means the next line of code will properly distribute the union we just created.

TypeScript
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?

Here we're distributing the union of functions U, then returning another union of functions with each element of U in an argument-type position. For example, if we started with a type A | D as T, U will be ()=>A | ()=>D, and now the signature will be (_:()=>A)=>void | (_:()=>D)=>void. Looks ridiculous but this is all a setup for the next part.

So when we get to extends (_: infer V) => void, it may seem at first glance we'd just get our original U back since we're inferring a type variable in the same position we moved the elements of U into. However, distribution of a type only occurs if the expression is a simple type variable. The complex expression prior to extends is not a simple type variable. So if distribution doesn't occur, what could V be inferred as if the complex expression is (_:()=>A)=>void | (_:()=>D)=>void?

TypeScript
//A reminder of the current state of the expression we're exploring
((_:()=>A)=>void | (_:()=>D)=>void) extends (_: infer V) => void

If you remember the union discussion earlier in this section, coercing a union of functions into T=>U form causes the argument types to be intersected therefore V is inferred as ()=>A & ()=>D.

Now let's look at the final line to see why we went through all this trouble just to turn a union into an intersection of functions.

TypeScript
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;

Here we're taking the function intersection and inferring a type variable for its return type. What's the return type of ()=>A & ()=>D? Again, if you recall the earlier discussion, coercing an intersection of function into T=>U form causes the return types to be intersected therefore U is inferred as A & D. Except remember assignability is checked from left to right with extends. We're going in the opposite direction! We solved for the general case where the T=>U form was more derived earlier, yet here it needs to be less derived.

TypeScript
()=>U = (()=>A) & (()=>D)

Since there are no arguments we only need to consider the answer from a standard covariant perspective. This runs into a small issue though since an intersection is assignable to its contituent types - both A and D work for U. Here there is no logically best solution. One just has to be picked, so Typescript decided on always returning the last return type in the intersection. U is therefore inferred as D. This also has the side-effect that intersections are not strictly commutative in Typescript (though they seem like it in many contexts).

TypeScript
declare const F1: ((a:string)=>string) & ((a:number)=>number);
declare const F2: ((a:number)=>number) & ((a:string)=>string);

//Non-commutative
type F1Last = typeof F1 extends (_:any)=>(infer L) ? L : never; //number
type F2Last = typeof F2 extends (_:any)=>(infer L) ? L : never; //string

//Another example of how intersections represent overloading.
F1('a'); //in my editor, this shows as "(a:string)=>string (+1 overload)".
F1(1); //this shows as "(a:number)=>number (+1 overload)".
//Same results as above if you use F2

That wraps up all the black magic going on. We build up a ridiculous looking function signature in order to turn around and unravel it utilizing the unique properties of how an argument type is inferred for function unions and how a return type is inferred for function intersections in order to grab the last type out of a union.

Important note: Unions in practice are usually well-ordered but the Typescript specification does not require this to be true. Therefore it's only safe to assume Last<T> will give you some type from the union but not the last type, even if in practice the latter ends up being true most of the time. This is because the intersection used to retrieve the last type is based on a union, therefore a change in the union ordering would change the ordering of the resulting intersection.

Decomposition

Decomposition is the process of breaking down a tuple into its individual parts. The easiest way to achieve this is using inference with a tuple signature that uses a rest parameter for the elements not explicitly handled.

TypeScript
type FirstElementOf<Tuple> = Tuple extends [infer E, ...infer _] ? E : never;
type LastElementOf<Tuple> = Tuple extends [...infer _, infer E] ? E : never;

You could use a function context for this as well:

TypeScript
type FirstElementOf<Tuple> =
    (Tuple extends any[] ? (...a:Tuple)=>void : never) extends (a:infer E, ...b:infer _)=>void ?
        E
        : never;

Functions don't support rest parameters at the start of the argument list so LastElementOf<Tuple> can't be implemented this way. This method has no advantage over the cleaner, easier first way; it's just an interesting footnote.

Mapped Types

As we've seen in previous sections, the extends clause can be very useful for creating a new type using an existing type. For example, A extends any ? ()=>A : never or A extends any ? { a: A } : never. Typescript also has a mechanism to create a new type using the existing type's properties instead of the type as a whole - mapped types.

TypeScript
//Basic structure
{
    [ <propertyName> in <properties> as <propertyRemap> ]: <propertyType>
}

propertyName is a variable that will hold each element returned from the set properties. It functions the same way the Javascript for...of loop works. propertyRemap is an optional expression where you can transform propertyName into some other valid value for use as the property index. propertyType is what the new type of the property will be. Let's look at some basic examples to see why all this is useful.

The following example uses a built-in Lowercase utility type inside a mapped type to create a new type that is the old type with all the property names lowercased.

TypeScript
type LowercaseProperties<T> =
    {
        [Key in keyof T as Lowercase<Key>]: T[Key]
    };

type TestType = { ABC: number; XYZ: string };
type TestTypeLower =  LowercaseProperties<TestType>; //{ abc: number; xyz: string }

That's only a small part of what it's capable of though. You can create new types from raw information:

TypeScript
type Create<PropertyNames extends string> =
    {
        [Key in PropertyNames as
            (Key extends 'id' ? Uppercase<Key> : Capitalize<Lowercase<Key>>)
        ]: Key extends 'id' ? number : string
    };

type IdCardType = Create<'id'|'firstName'|'lastName'>;
/* 
{ 
    ID: number; 
    Firstname: string; 
    Lastname: string; 
}
*/

You can also use it to change a type's readonly and optional modifiers by using + or - before the modifier. The following creates a readonly type with all properties required.

TypeScript
type MakeReadonly<T> =
    {
        +readonly [Key in keyof T]-?: T[Key]
    };

type ReadonlyID = MakeReadonly<IdCardType>;

This also demonstrates that if the optional as clause is left out, the iterator variable is used for the property index.

Templated String Literals

Exactly what it says on the tin. Be careful because as convenient as they are when you need them, the interpolated positions are cross-multiplied so can quickly spiral out of control into a compiler-crashing, monstrous union. For example, the following template creates every number from 0-1999. Add a couple more digits and watch Typescript wish it was never created.

TypeScript
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type UhOh = `${0|1}${Digit}${Digit}${Digit}`;

Templates are often useful for creating a new property index from the old one when mapping types.

TypeScript
//Look ma! I'm a real Java bean now!
type MakeJava<T> =
    {
        -readonly [K in keyof T]-?: T[K]
    } &
    {
        -readonly [K in keyof T as `get${Capitalize<Lowercase<K>>}`]: ()=>T[K]
    } &
    {
                       //Skip mutator for any property named 'ID'
        -readonly [K in Exclude<keyof T, 'ID'> as `set${Capitalize<Lowercase<K>>}`]
            : (set:T[K])=>void
    };

//Uses the ReadonlyID type from the previous section.
type JavaID = MakeJava<ReadonlyID>;
/* Resulting object
{
    ID: number;
    Firstname: string;
    Lastname: string;

    getID: ()=>number;
    getFirstname: ()=>string;
    getLastname: ()=>string;

    setFirstname: (set: string)=>void;
    setLastname: (set: string)=>void;
}
*/

That's really all there is to them.

Deferred Type Resolution

I won't even pretend to understand exactly how or why some type resolution behavior occurs. However, for whatever reason, there does seem to be a situation where type resolution is always deferred which can be very useful to help deal with the "type instantiation is too deep" error caused by Typescript's recursive depth limit. Whenever a type alias is an object definition's property's type and that object definition is returned from an alias, the type alias doesn't seem to need to be resolved before returning. This makes sense if we consider we don't need to resolve the property types in order to determine the object type to return.

It's probably easiest to showcase this in action:

TypeScript
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type FiftyNumbers = `${0|1|2|3|4}${Digit}`;

type UnionToFunctionIntersection<T> =
    (T extends any ? () => T : never) extends infer U ?
        (U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
            V
            : never
        : never;

type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;

type Push<Tuple extends unknown[], Element> = [...Tuple, Element];

type GenerateTuple<
    Union,
    IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
    true extends IsEmpty ?
        []
        : Last<Union> extends infer T ?
            GenerateTuple<Exclude<Union, T>> extends [...infer U] ?
                Push<U, T>
                : never
            : never;

//Error - Type instantiation is excessively deep and possibly infinite. ts(2589)
type tuple = GenerateTuple<FiftyNumbers>; 

All the above code is doing is creating a tuple using the elements of a given union. We know for a fact the type instantiation is not infinite because there are only 50 elements, so to avoid the error all we need to do is avoid the recursive depth limit. To do that we're going to re-write GenerateTuple to use deferred type resolution.

TypeScript
type PushFront<Tuple extends unknown[], Element> = [Element, ...Tuple];

type _GenerateTuple<
    Union,
    Tuple extends unknown[] = [],
    IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
    true extends IsEmpty ?
        { _wrap: Tuple }
        : Last<Union> extends infer T ?
            { _wrap: _GenerateTuple<Exclude<Union, T>, PushFront<Tuple, T>> }
            : never;

The original GenerateTuple peels off the union elements from the end then creates the tuple from those as the recursion unwinds. Since the new _GenerateTuple uses the recursion to create a deeply nested object definition, we'll use a slightly different approach to create the tuple - on wind-up instead of wind-down. Due to this, we need to push the elements to the front of the tuple to maintain the ordering and we need a new type parameter to pass the tuple along to the next call since we can no longer rely on stack returns. The resulting object will look something like this:

TypeScript
//abridged for my own sanity
{ _wrap: { _wrap: { _wrap: { _wrap: { _wrap: ['00', '01', '02', '03', '04'] } } } } }

So we've solved the recursive depth limit issue but it seems like we've traded it for another problem - how do we unwrap this in a depth-agnostic way? Luckily, this isn't as problematic as it seems at first.

TypeScript
type Unwrap<T> = T extends { _wrap: unknown } ? Unwrap<_Unwrap<T>> : T;
type _Unwrap<T> =
    T extends { _wrap: { _wrap: infer R } } ?
        { _wrap: _Unwrap<R> }
        : T extends { _wrap: infer R } ?
            R
            : T;

type GenerateTuple<Union> = Unwrap<_GenerateTuple<Union>>;

Let's tackle the core alias first - _Unwrap. What the first two lines do is collapse two _wrap objects into a single _wrap object, calling _Unwrap recursively on whatever is left. We're using deferred type resolution here again to ensure we don't hit the depth limit while unwrapping. Once there aren't two _wrap objects to collapse, there must be only one so we simply unwrap it. In the case _Unwrap gets called on a non-wrapped value, we just return that value (T).

So a single "outside" call of _Unwrap when completely resolved, including it's own recursive calls, results in halving the number of _wrap objects in the overall structure. In order to fully unwrap the value, we therefore need to call _Unwrap until we've fully unwrapped the inner value. This is what Unwrap does. It calls _Unwrap then sets up a recursive call to itself with the result. Only once that result no longer matches a _wrap object's structure (i.e. the result is fully unwrapped) does it return a result which will be the inner value.

TypeScript
type example = { _wrap: { _wrap: { _wrap: { _wrap: { _wrap: ['00', '01', '02', '03', '04']}}}}};

type unwrappedType = Unwrap<example>; //resulting type is ['00', '01', '02', '03', '04']

Viola! Using deferred type resolution we've successfully avoided the depth limit error and performed the operation.

Again, this is an educated guess on what exactly happens under the covers with what I call deferred type resolution, but my guess is what's effectively happening is that since the property type's aren't needed to have a complete object type, the object type is marked complete for return, the alias unwinds, then immediately after the object is returned, the system realizes the property type's aren't fully resolved, which then triggers the alias(es) within the object to get called. This means by using this technique we only ever actually use a single stack depth beyond what we're currently at to process the recursive alias. This also means that unfortunately we can't make the wrapping "cleaner" because the alias used to wrap would trigger the resolution of the inner property types on return, leading us right back into the depth limit error.

TypeScript
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;

type FiftyNumbers = `${0|1|2|3|4}${Digit}`;

//The "clean" way to wrap objects, abstracting out the property name details.
type Wrap<T> = { _wrap: T };

type GenerateTuple2<Union> = Unwrap<_GenerateTuple2<Union>>;

//All we need to do to break the solution is replace the raw object wrapping
//that is recursive with the Wrap<> call.
type _GenerateTuple2<
    Union,
    Tuple extends unknown[] = [],
    IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
    true extends IsEmpty ?
        { _wrap: Tuple } //Replacing this with Wrap<Tuple> would work because it only
                         //executes once since it's the base case for our recursion.
        : Last<Union> extends infer T ?
            //This blows up though.
            Wrap<_GenerateTuple2<Exclude<Union, T>, PushFront<Tuple, T>>>
            : never;

//Uh oh...
type tuple2 = GenerateTuple2<FiftyNumbers>;

Thanks for reading!

References

Typescript Handbook

GitHub: Typescript Recursive Conditional Types PR#40002

GitHub: Typescript Function Intersection Issue#42204

Susisu: How to Create Deep Recursive Types

History

9/2/21: Initial release.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United States United States
Software engineer dedicated to constantly learning and improving with a focus on self-documenting code and informed design decisions. Interested in most everything. Currently diving down the rabbit-hole of functional programming and category theory.

Comments and Discussions

 
-- There are no messages in this forum --