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
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}
- ['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.firstNameYou can know that the corresponding value type is string
- given hobby.${number}You can know that the corresponding value type is string
- given
-
-
-
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
inferkeyword to declaratively introduce a new generic type variable namedIteminstead of specifying how to retrieve the element type ofTwithin 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
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