Practical application of the new features of TypeScript 4.3

Practical application of the new features of TypeScript 4.3

This article will take you to understand the advanced features of TS4.3 by solving the problems encountered in actual work and analyzing the solutions layer by layer. Let's take a look.

TypeScript, which has become standard on the front end, will release version 4.3 at the end of May. As a minor version iteration, there are no amazing new features at first glance. But if you really continue to follow TypeScript, then one of the updates is worth paying attention to:

Template String Type Improvements

Why is it worth noting? Take a look at the three update records since TS 4.0:

Variadic Tuple Types added in version 4.0

Template Literal Types added in version 4.1

4.3 version perfect Template Literal Types

Then I tell you now that Tuple Types and Template Literal Types are actually a pair of close buddies. So, wise, have you guessed that since TS continues to exert strength in Tuple Types and Template Literal Types, there is a high probability that you should be able to use them to accomplish things that were previously impossible.

As for me, as early as April, I discovered this new feature of TS 4.3 that will be released, and I have experienced it in the preview version, and solved a very interesting little problem: how to convert all possible legal paths of object types Statically typed.

Now let me take you to see what kind of real problem can be solved by Template Literal Types after 4.3 enhancement.

Restore the problem site

Our team currently uses FinalForm to manage the state of the form in the current project, but this is not the point. The point is that one of the change methods, which is almost identical to the lodash set method, cannot be completely type-safe. This leads us to use the slightly ugly as any to escape when writing related TS code. For specific examples, see the code of :

type NestedForm = { name : [ 'Zhao' | ' ' | 'Sun' | ' ' , string ]; age: number ; articles: { title : string ; sections: string []; date: number ; likes: { name : [ string , string ]; age: number ; }[]; }[]; } //A commonly used API in FinalForm, with almost the same semantics as set in lodash interface FormApi<FormValues = Record<string, any>> { change : <F extends keyof FormValues>(name: F, value?: Partial<FormValues[ F]>) => void } const form: FormApi<NestedForm> =//pretend to have a form instance //Basic use form.change('age', '20')//This is type safe //A large number of real usage scenarios are actually not safe, but completely reasonable, so you can only use as any to escape form.change('name.0','Liu') form.change('articles.0.title','some string') form.change('articles.0.sections.2','some string') //Escape code in the project <Select placeholder="Please select the type" onChange={Kind => { //Clear other fields, only keep Kind form.change(`${field}.Env.${i}` as any, {Kind }); }} > Copy code

So the question is: can we make similar methods completely type-safe?

I'm not hiding the answer: To solve this kind of problem, you need the Template Literal Types after 4.3 enhancements and the new Variadic Tuple Types in version 4.0, plus some other advanced features that have already existed.

Seeing these new and advanced words, a proper high-level TS interview question there is nothing. And I can assure you that if you can do the following content, you can know what it is and why it is, you will pass the TS.

Solution dismantling, from shallow to deep

The first step: core technical support

  • In many cases, solutions tend to have hidden in the question of

    • The type-safe part of the change method is the outermost key of the object :

      • name
      • age
      • articles
    • The unsafe part is the other nested paths of the object:

      • name.0
      • name.1
      • articles.0.likes.0.age

Our goal is actually very clear: get all possible paths of the object . Maybe this is still a bit vague, but if I change my words, you may understand: give you a binary tree, the problem is to start from the root node, all possible paths.

But do these have anything to do with Template Literal Types? ! Of course there is, and very much. we all know

articles.0.likes.0.age
It is a string, but it is a template string type. It is precisely it that allows us to express all nested subpaths of an object at the type level.

Step 2: Template Literal Types and Variadic Tuple Types are amazing

This step does not require you to understand all of it. First have a general concept and feeling, let you know first, Template Literal Types with Variadic Tuple Types, and then use some generic techniques, you can stably get all the embedded objects. Set path. How to use generics to solve all nested sub-paths of an object will be described in detail later.

  • Core operation

    • join

      • ['articles', number] =>
        articles.${number}
  • split

    • articles.${number}
      =>'['articles', number]
  • Detailed operation

      • {name: {firstName: string, secondName: string }, hobby: string[]}

      • Each path is a tuple, and all paths are the union of all tuples

      • ['name'] | [hobby] | ['name','firstName'] | ['name','secondName'] | ['hobby', number]

      • Tuple can be easily converted to template string type

      • name
        |
        hobby
        |
        name.firstName
        |
        name. secondName
        |
        hobby.${number}

      • Then it is how to get the type of value corresponding to path according to path

        • given
          name.firstName
          You can know that the corresponding value type is string
        • given
          hobby.${number}
          You can know that the corresponding value type is string
  • Conclusion: template string type and tuple type can be converted equivalently

Step 3: Advanced features of TS that you may not know

Before explaining the generic functions in detail, this section wants to introduce some advanced features of TS that you may not understand. If you are very confident, you can skip this section and go directly to the generic functions. If you find that you don t understand, It's not too late to look back at this section.

1. TS type system you may not know

We know that the core function of TS is a static type system, but do you really understand the TS type system? Let me ask you a question to test: Is the type of TS a collection of values?

This is a very interesting question. The correct answer is: Except for a special case, types in programming languages are indeed collections of values. But because of the existence of special cases, we cannot regard types in programming languages as collections of values. This special case is called never in TS and has no corresponding value. It is used to indicate that the code will crash and exit or fall into an infinite loop . Moreover, never is a subtype of all types, which means that any worry-free function that you write that seems to be protected by static typing may crash or loop indefinitely during actual operation. Reluctantly, this possibility that no one likes is a legal behavior allowed by the static typing system. Therefore, static typing is not a panacea.

2. Conditional types

At the heart of most useful programs, we have to make decisions based on input.

Conditional types help describe the relation between the types of inputs and outputs.

The introduction of condition types is the basis for TS generics to start to shine. We all know that programming cannot leave the decision with conditional branches. If else is everywhere in any actual programming project.

The most common conditional branch in TS generics looks like this:

SomeType the extends ? OTHERTYPE TrueType: FalseType; Copy the code

We can do some useful things based on conditional branches. For example, to determine whether a type is an array type, and if it is, it returns the element type of the array.

type Flatten<T> = T extends unknown[]? T[ number ]: T; //Extracts out the element type. type Str = Flatten< string []>; //string . Leaves//The type alone type the Num = The Flatten < Number >; //Number copy the code
Distributive Conditional Types

When conditional types act on a generic type , they become distributive when given a union type .

In addition to using branches to make decisions, programming is also inseparable from loops. After all, handwriting one by one is completely unrealistic. TS generic functions do not have for or while loops in the conventional sense, but they have Distributive Conditional Types, which have very similar functions. The map method of the array is just that the object is the union type. The specific performance can directly look at the following icon:

3. Inferring Within Conditional Types

There is also an indispensable high-order feature about condition types: infer inference. TS's infer capability allows us to use declarative programming methods to accurately extract the part we are interested in from a complex compound type.

Here, we used the

infer
keyword to declaratively introduce a new generic type variable named
Item
instead of specifying how to retrieve the element type of
T
within the true branch.

For example, the generic type of the above extracted array element type can be implemented with infer as follows. Does it look more concise and less effortless?

of the type Flatten <Type> = Type the extends Array <Infer Item> Item: Type;? Copy the code

4. Recursive operation of tuple tuple and template string type

The content before this section is just a warm-up, the recursive generics of this section is the core of this article. The first step of solution disassembly has pointed out that the core technical support is Variadic Tuple Types and Template Literal Types. This section will introduce the recursive operation of tuple and template string on the basis of conditional generics and infer.

Tuple is an Array with fixed length and fixed element type. As shown in the following code, Test1 is a tuple, length is fixed to 4, and each element type is also fixed. JoinTupleToTemplateStringType is a generic function that can convert a Tuple into Template Literal Types, and the result obtained when applied to Test1 is

names.${number}.firstName.lastName
. Specific to the implementation of JoinTupleToTemplateStringType, in addition to the use of conditional types and infer, we also use a powerful TS generic feature: recursion. If you have a little understanding of algorithms, you will know that the core of any algorithm operation is branch and loop, and loops are completely equivalent to recursion, which means that any algorithm implemented with loops can theoretically be implemented with recursion, and vice versa. In the current mainstream programming languages, most of them are based on loops, and many people may even have heard some sayings such as "don't write recursion". But at the TS generic level, we can only use recursion and conditions to implement some interesting generic functions. I have added detailed comments to the following code. Take your time to read it. Don't be afraid, you will definitely be able to understand it. Because recursion has a feature, it may not be easy to write, but it is often easier to read (provided that the single logic is complete and there is no nested recursion).

type Test1 = [ 'names' , number , 'firstName' , 'lastName' ]; //Assuming that the type of Tuple element that needs to be processed will only be string or number //The reason for this assumption is that the key of the object is generally Say, only string or number type JoinTupleToTemplateStringType<T> = T extends [infer Single] //Here is the recursive base, used to judge whether T is already the simplest one-element Tuple ? Single extends string | number //If it is a recursive base, extract the specific type of Single ? ` ${Single} ` : never //If the recursive base has not been reached , continue recursion : T extends [infer 1. ...infer RestTuple]//array deconstruction completely analogous JS First? The extends String | Number ? ` $ {First} . $ {JoinTupleToTemplateStringType <RestTuple>} ` //recursive : Never : Never ; type TestJoinTupleToTemplateStringType = JoinTupleToTemplateStringType <Test1>; duplicated code

In the above recursive operation, the Tuple is converted into a Template Literal Type, and the following recursive generic type is the opposite, which converts a Template Literal Type into a Tuple. The code has also been commented in detail, don't be afraid, as long as you read it slowly, you will be able to understand it.

type Test2 = `names. ${ number } .firstName.lastName. ${ number } ` ; type SplitTemplateStringTypeToTuple<T> = T extends ` ${infer First} . ${infer Rest} ` //This branch indicates the need to continue recursion ? First extends ` ${ number } ` ? [ Number , ...SplitTemplateStringTypeToTuple<Rest>] //completely similar to JS array structure : [1. ...SplitTemplateStringTypeToTuple<Rest>] //This branch means reaching the recursive base, which is either nubmer or string : T extends ` ${ number } ` ? [ Number ] : [T]; type TestSplitTemplateStringTypeToTuple = SplitTemplateStringTypeToTuple<Test2>; Copy code

The last step: solving the recursive generics of all nested subpaths of the object

Finally reached the last step, the real solution, a recursive generic AllPathsOf that solves all nested sub-paths of the object. AllPathsOf is not complicated. It consists of two nested generics. The two nested generics have only seven or eight lines each, and they add up to fifteen lines. Isn t it okay? So the most critical step is to think of finding TuplePaths first, and then paving it. The paving step we have shown before is to use a recursive generic to convert a Tuple into a Template Literal Type. So there is only one question left: how to extract all the sub-paths of the object and express it as a Tuple Union. RecursivelyTuplePaths itself is not complicated, there are detailed comments in the code below, don't be afraid, watch it slowly, you will definitely understand it.

The rest is ValueMatchingPath. It seems that the code is a bit more complicated than AllPathsOf, but because it is only an additional function, I will not introduce it in detail here. If you are interested, you can see the code. I believe that after the previous rounds of recursive generics, this one is slightly longer. It's not a problem.

// //Supported environment: TS 4.3+ // /** Get all subpaths of nested objects*/type AllPathsOf<NestedObj> = object extends NestedObj ? never //First organize all sub-paths into tuple union, and then flatten each tuple into Template Literal Type : FlattenPathTuples<RecursivelyTuplePaths<NestedObj>>; /** Given the sub-path and nested objects, get the value type corresponding to the sub-path*/ export type ValueMatchingPath<NestedObj, Path extends AllPathsOf<NestedObj>> = string extends Path ? any : object extends NestedObj ? any : NestedObj extends readonly (infer SingleValue)[] //Array situation ? Path extends ` ${ string } . ${infer NextPath} ` ? NextPath extends AllPathsOf<NestedObj[ number ]> //Path has nesting situation, continue Recursion ? ValueMatchingPath<NestedObj[ number ], NextPath> : never : SingleValue //Path has no nesting situation, the item type of the array is the target result : Path extends keyof NestedObj //Record situation ? NestedObj[Path] //Path is one of the keys of Record, then the target result can be returned directly : Path extends ` ${infer Key} . ${infer NextPath} ` //Otherwise continue recursion ? Key extends keyof NestedObj ? NextPath extends AllPathsOf<NestedObj[Key]> //Enter recursion through two levels of judgment ? ValueMatchingPath<NestedObj[Key], NextPath> : never : never : never ; /** * Recursively convert objects to tuples, like * `{ name: {first: string} }` -> `['name'] | ['name','first']` */ type RecursivelyTuplePaths<NestedObj> = NestedObj extends (infer ItemValue)[] //Array case //Array case needs to return a number, and then continue recursion ? [ number ] | [ number , ...RecursivelyTuplePaths<ItemValue>] //Completely similar to the JS array construction method : NestedObj extends Record< string , any > //Record situation ? //The record situation needs to return the outermost key of the record, and then continue recursion | [keyof NestedObj] | { [Key in keyof NestedObj]: [Key, ...RecursivelyTuplePaths<NestedObj[Key]>]; }[Extract<keyof NestedObj, string >] //It s a little bit complicated here, but what we do is actually construct an object, and the value is the tuple we want //Finally , we extract the value //It s neither an array nor When record, it means that the basic type is encountered, the recursion ends, and an empty tuple is returned. : []; /** * Flatten tuples created by RecursivelyTupleKeys into a union of paths, like: * `['name'] | ['name','first'] ->'name' |'name.first'` */ type FlattenPathTuples<PathTuple extends unknown[]> = PathTuple extends [] ? never : PathTuple extends [infer SinglePath] //Note that [string] is Tuple ? SinglePath extends string | number //Extract the Path type by conditional judgment ? ` ${SinglePath} ` : never : PathTuple extends [infer PrefixPath, .. .infer RestTuple] //Is it similar to the syntax of array destructuring? ? PrefixPath extends string | number //Continue recursion through conditional judgment ? ` ${PrefixPath} . ${FlattenPathTuples<Extract<RestTuple, ( string | number )[]>>}` : never : string ; /** * With the new ability of TS 4.3 (template string type enhancement) to transform the change method in FormApi interface, the usability is almost perfect **/ interface FormApi<FormValues = Record<string, any>> { change : <Path extends AllPathsOf<FormValues>>( name: Path, value?: Partial<ValueMatchingPath<FormValues, Path>> ) => void; } //Nested Form type for demonstration interface NestedForm { name: ['Zhao' |'Money' |'Sun' |' ', string]; age: number; articles: { title: string; sections: string[]; date: number; likes: { name: [string, string]; age: number; }[]; }[]; } //Pretend to have a change method of a NestedForm type form instance const change: FormApi<NestedForm>['change'] = (name, value) => { console.log(name, value); }; // Try it out let index = 0; change(`articles.0.likes.${index}.age`, 10); change(`name.${index}`,' ');//Actually it is still not safe enough here, you can think about how to be safer /** All sub-paths extracted, put them here for visual display*/ type AllPathsOfNestedForm = | keyof NestedForm | `name.${number}` | `articles.${number}` | `articles.${number}.title` | `articles.${number}.sections` | `articles.${number}.date` | `articles.${number}.likes` | `articles.${number}.sections.${number}` | `articles.${number}.likes.${number}` | `articles.${number}.likes.${number}.name.${number}` | `articles.${number}.likes.${number}.age` | `articles.${number}.likes.${number}.name`; Copy code

The last step: use tail recursion to optimize the performance of generic functions

The last step is a bonus, additional optimization. You can see that the preceding AllPathsOf is a recursive with high running complexity. This should be a common problem with recursion, and some friends do not like recursion because of this. But in fact, this problem of recursion can be circumvented by technical means. This technique is tail recursion.

Let's use the classic fibonacci sequence to actually feel the difference between recursion, tail recursion, and loop:

//The recursive version of fibonacci, the performance is urgent, it is simply intolerable function fibRecursive ( n: number ): number { return n <= 1 ? N: fibRecursive(n- 1 ) + fibRecursive(n- 2 ); } //The tail recursive version of fibonacci, turning decay into magic, soaring performance function fibTailRecursive ( n: number ) { function fib ( a: number , b: number , n: number ): number { return n === 0 ? A: fib (b, a + b, n- 1 ); } return fib( 0 , 1 , n); } //The looping version of fibonacci, seems to be the same as the tail recursive version? function fibLoop ( n: number ) { let [a, b] = [ 0 , 1 ]; for ( let i = 0 ; i <n; i++) { [a, b] = [b, a + b]; } return a; } Copy code

Yes, the performance of tail recursion is the same as loop in terms of time complexity.

Let's see how tail recursion is used in TS generics:

type OneLevelPathOf<T> = keyof T & ( string | number ) type PathForHint<T> = OneLevelPathOf<T>; //The P parameter is a state container, used to carry the recursive results of each step, and finally help us achieve tail recursion type PathOf<T, K extends string , P extends string = '' > = K extends ` ${infer U} . ${infer V} ` ? U extends keyof T //Record ? PathOf<T[U], V, ` ${P} ${U} .` > : T extends unknown[] //Array ? PathOf<T[ number ], V, ` ${P} ${ number } .` > : ` ${P} ${PathForHint<T>} ` //Go to this branch, indicating that the parameter is wrong, and prompt the user for the correct parameter : K extends keyof T ? ` ${P} ${K} ` : T extends unknown[] ? ` ${P} ${ number } ` : ` ${P} ${PathForHint<T>} ` ; //Go to this branch, indicating that the parameter is wrong, and prompt the user for the correct parameter /** * Use tail recursive generics to transform the change method in FormApi interface to improve performance * */ interface FormApi<FormValues = Record<string, any>> { change : <Path extends string>( //Here it is judged whether the given name parameter is a subpath of FormValues on demand //Compilation performance will be significantly improved name: PathOf<FormValues, Path>, value?: Partial<ValueMatchingPath<FormValues, Path>> ) => void; } Copy code

Concluding remarks

This is the end of TS 4.3 Template Literal Types practice. These slightly complicated but logically clear recursive generics must be somewhat difficult to understand. If you really don't understand it, it doesn't matter. You can take your time later. But to truly master TS, this level of recursive generics must be mastered, so this article does have some value

Reference link

github.com/microsoft/T...