“This article has participated in the call for good writing activities, click to view: the back end, the big front end double track submission, 20,000 yuan prize pool waiting for you to challenge!”
preface
Always wanted to write this article. But due to the lack of early preparation, has been tinkering, until now released. This article is some of my hands-on experience with typescript over the course of a year as I’ve come to understand and love the language. Like most people, I started with anyScript and slowly moved from roughing up types to using type inference and generics to being able to write some type utility functions myself. This article will detail the pitfalls I encountered and some of the practices of type-oriented programming.
This article doesn’t focus too much on the basics of typescript and requires an understanding of basic typescript syntax and the concept of generics. If you want to learn the basics, check out the typescript overview series I wrote earlier.
Based on article
This is a foundational article, but not the foundational part of typescript. On the contrary, these items may be used by beginners or relatively infrequently, so make sure you have a basic understanding of these items first. The following is just a hint or supplement to some of the items that are often used in daily development.
The keyof, in, and [] symbols operate on index types
The following three operators all operate on types that have indexes, and are mostly used only when type defines a type (except [] for index types).
First of all, an explanation of these three symbols is given:
-
[] is the index access operator. We can use the [] form equivalent to object writing alone or in conjunction with the keyof and in operators.
interface Foo { a: number b: string c: boolean } // There will be a hint type FooKey = Foo['a'] // number Copy the code
As simple as that, the [] operator lets us get the type of an index directly. It also allows us to pass in union types:
interface Foo { a: number b: string c: boolean } // string | number | boolean type FooKey = Foo['a' | 'b' | 'c'] // All types that match the above attributes can be federated Copy the code
The result returned is also a combined type of all index value types. Work with other operators to read on.
-
Keyof is the index type query operator. Given that T is a type (which is typically an object type, or a type that can be written with interface), keyof T produces a type that is a combination of T’s property name string literal types. Here’s an example:
interface Foo { a: number b: string c: boolean } // 'a' | 'b' | 'c' type FooKey = keyof Foo Copy the code
Combined with the [] operator above:
interface Foo { a: number b: string c: boolean } // string | number | boolean type FooKey = Foo[keyof Foo] Copy the code
What do you say? Doesn’t that make it easier.
Note: Although we can use it like this:
type Bar = 'a' | 'b' | 'c' type BarKey = keyof Bar Copy the code
Bar will be recognized as a string, and keyof will iterate over all the string properties and methods. This is not the answer we want. That’s why I emphasize that these operators are indexed and should be used by indexed objects or arrays.
-
In is the index traversal operator. Typescript complements typescript’s in operator on types. The IN operator allows us to iterate over each item of a union type and separate it into an index, just as we execute code:
type Foo = 'a' | 'b' | 'c' /* type FooObj = { a: any; b: any; c: any; } * / type FooObj = { [K in Foo]: any } Copy the code
As you can see, the IN operator generates a new object type from the iterated union type.
This is interesting. With the previous keyof operator, we can combine them to map one type to another:
// Change all indexes in T to readable type type Partial<T> = { [P inkeyof T]? : T[P] }Copy the code
In fact, many of typescript’s built-in utility types use these mapping-related operators, and many of our examples use these features as well, so keep them in mind.
Const type assertion
In typeScript 3.4, we introduced a new construct for literal values called const assertions. Its syntax is type assertion, const instead of the type name (e.g. 123 as const). When we construct new literal expressions using const assertions, we can signal to the language:
- None of the literal types in this expression should be extended
- Object text gets the readonly property
- Array literals become readonly tuples
// Type '"hello"'
let x = 'hello' as const // The type is 'hello' and cannot be changed to string
// Const a = 'hello'
// As const is used on arrays or objects
// Type 'readonly [10, 20]'
const y = [10.20] as const
// Type '{ readonly text: "hello" }'
const z = { text: 'hello' } as const
Copy the code
Const assertions can help us reduce some of the more complex type writing, for example:
// Type: function Foo(): (number | (() => void))[]
function Foo() {
let count = 0
const setCount = () = > {
count++
}
return [count, setCount]
}
Copy the code
The return value of the function is an array of associative type without as const. This is clearly not what we want, in which case we need to manually specify the return value of the function. Of course, it’s easier to add as const assertion
// Type: function Foo(): readonly [number, () => void]
function Foo() {
let count = 0
const setCount = () = > {
count++
}
return [count, setCount] as const
}
Copy the code
This way, you get the correct tuple type when you call it.
Note:
const
Assertions can only be applied immediately to simple literal expressions.// Error! A 'const' assertion can only be applied to a // to a string, number, boolean, array, or object literal. let a = (Math.random() < 0.5 ? 0 : 1) as const // Works! let b = Math.random() < 0.5 ? (0 as const) : (1 as const) Copy the code
const
The context does not immediately convert the expression to completely immutable (just shallow).const arr = [1.2.3.4] let foo = { name: 'foo'.contents: arr } as const foo.name = 'bar' // error! foo.contents = [] // error! foo.contents.push(5) / /... works! // Const arr = [1, 2, 3, 4] as const Copy the code
Interface declaration merge
We know that there are ways to define a typeinterface
andtype
Two,interface
There is a very special feature that can merge types as follows:
Therefore, the type defined using an interface can be changed later. However, a type defined using type does not have the type merge feature. After being defined, it cannot be modified again and can only be used as an alias.
Based on this characteristic, we may be able to explain the following question:
export interface IFoo {
id: string
}
export interface IFoo2 {
id: string
[key: string] :string
}
type TFoo = {
id: string
}
function foo(payload: Record<string.string>) {}
const iPayload: IFoo = {
id: 'payload'
}
const iPayload2: IFoo2 = {
id: 'payload'
}
const tPayload: TFoo = {
id: 'payload'
}
/* Arguments of type "IFoo" cannot be assigned to arguments of type "Record
". Missing index signature in type "IFoo". * /
,>
foo(iPayload)
// Normal execution
foo(iPayload2)
// Normal execution
foo(tPayload)
Copy the code
In the above example, only the index type interface and the type defined by type can be passed to foo. Because of interface’s ability to merge types, there is no guarantee that IFoo’s type will change later, but after adding the index type, We specify that the value of the index type must be string, which matches the parameter type of the function no matter how it changes, thus passing typescript detection.
As mentioned above, when our foo function is defined as type:
function foo(payload: Record<string.any>) {}
Copy the code
The index value any is compatible with any types, so you don’t have to worry about merging the types of interfaces.
Basic usage of generics
Pay attention! Generics play a big role in typescript and are almost the most common feature we use every day, so I think it’s important to be familiar with generic parameters.
Generics can be declared when a class, function, or type is created, and we can dynamically limit the types of these structures by passing in their parameter values (we don’t do this manually in most usage scenarios, but use typescript’s auto-inference capabilities, which we’ll discuss in more detail).
Here is a brief list of the three corresponding writing formats:
- Function:
function foo<T> (bar: T) :T { return bar } foo<number> (1) Copy the code
The call is passed in a generic parameter, limiting the type of the parameter that is passed in (it is also possible not to write number, which would manually restrict the type that is passed in foo to be number).
- Class:
// Define generic parameters here class Foo<T> { // borrow generic parameter constraint method types add(value: T): void{}}// Use generics to implement the internal algorithm for different variable types of a class, which is more efficient than any const foo1 = new Foo<number>() foo1.add(1) // Only the type of numbwr can be entered const foo2 = new Foo<string>() min2.add('a') // Only string can be passed in Copy the code
- Type:
/* type Foo
= { value: T keys: (keyof T)[] } */ interface Foo<T> { value: T keys: (keyof T)[] } // Identify assignable types const foo: Foo<{ a: number; b: string} > = {value: { a: 1.b: '1' }, keys: ['a'.'b']}Copy the code
Also, generics can be constrained using the extends keyword:
// T extends String means that the type of T extends from a substring, or that T is a subset of string
function foo<T extends string> (bar: T) :T {
return bar
}
foo<number> (1) // error!
Copy the code
In this case, because we’re specifying that T must be a subset of string, any incoming number type will be an error.
Function overloading
For those of you who have used other languages like Java and C++, function overloading in typescript is not at compile time overload. Because it needs to be interoperable with javascript, it can’t generate multiple functions, so it’s just function signature overloading, and we’ll have to make our own judgments about the relevant conditions inside the actual function body.
Here’s a simple way to use it:
// The first two are overloaded signatures, the last one is the real function implementation, the first two are the real function type, and the last implementation function parameter and return value type must be compatible with all of the above signatures
function fun(name: string) :string
function fun(age: number) :string
function fun(payload: string | number) :string {
if (typeof payload === 'string') {
return 'name: '+payload
} else {
return 'age: ' + payload
}
}
// Fun can pass only string and number arguments. If you pass other arguments, an error will be reported
console.log(fun('foo'))
console.log(fun(18))
console.log(fun(true)) // error
Copy the code
Usage scenarios
Function overloading is used when the function has multiple fixed arguments or return values, such as when we want to implement a function that supports both callback functions and promises:
function add(a: number, b: number, cb: (res: number) = >void) :void
function add(a: number, b: number) :Promise<number>
function add(
a: number,
b: number, cb? : (res:number) = >void
) :Promise<number> | void {
const res = a + b
if (cb) {
cb(res)}else {
return Promise.resolve(res)
}
}
// The following two methods are indicated
add(1.2.(res) = > console.log(res))
add(1.2).then((res) = > console.log(res))
// You can only choose one or the other
add(1.2.(res) = > console.log(res)).then((res) = > console.log(res))
Copy the code
The use of condition types
Conditional types in typescript rely on the extends keyword, which is similar to a ternary expression in the following format:
type Type<T> = T extends U ? X : Y
Copy the code
Return X if T is a subclass of U, Y otherwise.
More specifically, you can do this:
type TypeName<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T extends undefined
? undefined
: T extends() = >void
? () = > void
: object
type Type = TypeName<() = > void> // () => void
type Type = TypeName<string[] >// object
Copy the code
Since there is no if statement, we usually use this nesting of ternary expressions for deep judgment.
Distributed condition type
A distributed condition type is a subset of the condition type. It is also called a distributed condition type if the type to be examined in the condition type is naked Type parameter. Distributed condition types are automatically distributed to union types when instantiated.
For example, instantiate T extends U? X, Y, T type | A | B C, will be resolved as (A extends U? X : Y) | (B extends U ? X : Y) | (C extends U ? X, Y).
Naked type parameters: other types of parameters, such as (string | number) [] is the other types of the parameters of the package, and string | number, it is not.
Take our example from above:
type TypeName<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T extends undefined
? undefined
: T extends() = >void
? () = > void
: object
type Type = TypeName<(() = > void) | string[] >// () => void | object
Copy the code
As you can see, our union type is parsed separately to form a new union type.
The use of infer
The infer operator must be used with a conditional type (with the extends keyword). Infer is used for type inference and assignment, usually followed by a generic variable whose return type is given to a subsequent generic variable that can be used to parse some aspect of it. This operator is often important when writing type tools. Here’s an example:
Infer can infer the types of elements in the array and assign values to U
type Type<T> = T extends Array<infer U> ? U : T
// Check that the type is array
type Test = Type<string[] >// string
// Check that the type is not an array
type Test2 = Type<string> // string
Copy the code
The basic usage method is simple, but we can infer a lot of types from the original type, as can be seen in the following sections.
Using built-in types
Typescript provides us with a number of useful built-in typing tools that we can easily use to get the type we want.
As for how to use it. For example, I would use Record
in my project to create an object type to replace any with an index type constraint. Use the form Required
[‘ XXX ‘] to convert all values of an index Type to Required and get the Required definition Type (directly defining variable types with existing types), etc.
interfaceButtonProps { onClick? :(e: MouseEvent) = > void
}
const foo: Record<string.any> = {}
const bar: Required<ButtonProps>['onClick'] = (e) = > {
console.log(e)
}
Copy the code
For those of you interested, take a look at the utility types repository, which has some very versatile extension utility functions.
For index types
The main types of indexes are Partial, Required, Readonly, Pick, Omit, and Record.
// For the index type
/** * Make all properties in T optional */
// make all attributes of T optional
type Partial<T> = {
[P inkeyof T]? : T[P };/** * Make all properties in T required */
// Make all attributes of T mandatory
type Required<T> = {
[P inkeyof T]-? : T[P]; };/** * Make all properties in T readonly */
// Make all attributes of T read only
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
/** * From T, pick a set of properties whose keys are in the union K */
// Select K from T;
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
/** * Construct a type with the properties of T except for those in type K. */
// Corresponding to Pick, extract attributes from T that are not in the K set (filter operation)
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
/** * Construct a type with a set of properties K of type T */
// Construct a type with the collection elements in K as attributes and T as values
type Record<K extends keyof any, T> = {
[P in K]: T;
};
Copy the code
For union types
The main types of associations are: Exclude, Extract, and NonNullable
// For union types
/** * Exclude from T those types that are assignable to U */
// Remove type U from type T
type Exclude<T, U> = T extends U ? never : T;
/** * Extract from T those types that are assignable to U */
// Select U from T
type Extract<T, U> = T extends U ? T : never;
/** * Exclude null and undefined from T */
// Remove null and undefined from T
type NonNullable<T> = T extends null | undefined ? never : T;
Copy the code
In view of the function
The main ones for functions are: Parameters, ReturnType, ConstructorParameters, InstanceType, and ThisType for this
// For functions
/** * Obtain the parameters of a function type in a tuple */
// Get the function argument type (a tuple)
type Parameters<T extends(... args:any) = >any> = T extends(... args: infer P) =>any ? P : never;
/** * Obtain the return type of a function type */
// Get the function return value type
type ReturnType<T extends(... args:any) = >any> = T extends(... args:any) => infer R ? R : any;
/** * Obtain the parameters of a constructor function type in a tuple */
// Get the constructor argument type (a tuple)
type ConstructorParameters<T extends new(... args:any) = >any> = T extends new(... args: infer P) =>any ? P : never;
/** * Obtain the return type of a constructor function type */
// Get the instance type of the constructor
type InstanceType<T extends abstract new(... args:any) = >any> = T extends abstract new(... args:any) => infer R ? R : any;
/** * Marker for contextual 'this' type */
// Mark the type of this, doubt it, I have never used this type myself, the actual project is basically useless, if you want to mark this, just assign the type to this.
interface ThisType<T> { }
Copy the code
For string templates
Uppercase, Lowercase, Capitalize, and Uncapitalize are all built-in typescript implementations for string templates. Keep this tool type in mind.
// For the string template
/** * Convert string literal type to uppercase */
// Convert all characters of the string type to uppercase
type Uppercase<S extends string> = intrinsic;
/** * Convert string literal type to lowercase */
// Convert all characters of the string type to lowercase
type Lowercase<S extends string> = intrinsic;
/** * Convert first character of string literal type to uppercase */
// Convert the first character to uppercase
type Capitalize<S extends string> = intrinsic;
/** * Convert first character of string literal type to lowercase */
// Convert the first character to lowercase
type Uncapitalize<S extends string> = intrinsic;
Copy the code
Use of type protection
Type protection is also an interesting feature in typescript. It helps us narrow down type choices. Encapsulating type protection for a specific type can simplify scenarios where type assertions are used heavily.
The main type protection mechanisms come in three forms:
- use
typeof
:function padLeft(value: string, padding: string | number) { if (typeof padding === 'number') { return Array(padding + 1).join(' ') + value } if (typeof padding === 'string') { return padding + value } throw new Error(`Expected string or number, got '${padding}'. `)}Copy the code
Note:
typeof
Only two forms of type protection can be recognized:typeof v === "typename"
andtypeof v ! == "typename"
, among them,"typename"
It must be"number"
."string"
."boolean"
or"symbol"
. - use
instanceof
:interface Padder { getPaddingString(): string } class SpaceRepeatingPadder implements Padder { constructor(private numSpaces: number) {} getPaddingString() { return Array(this.numSpaces + 1).join(' ')}}class StringPadder implements Padder { constructor(private value: string) {} getPaddingString() { return this.value } } function getRandomPadder() { return Math.random() < 0.5 ? new SpaceRepeatingPadder(4) : new StringPadder(' ')}/ / type for SpaceRepeatingPadder | StringPadder let padder: Padder = getRandomPadder() if (padder instanceof SpaceRepeatingPadder) { padder // Type refined to 'SpaceRepeatingPadder' } if (padder instanceof StringPadder) { padder // Type refinement to 'StringPadder' } Copy the code
Note:The right side of instanceof is required to be a constructor, which typeScript refines to:
- Of this constructor
prototype
Property if its type is notany
Words. - Construct the type union returned by the signature.
- Of this constructor
- use
is
(Custom type protection) :class Fish { swim() { console.log('swim')}}class Bird { fly() { console.log('fly')}}function isFish(pet: Fish | Bird) :pet is Fish { return(<Fish>pet).swim ! = =undefined } Copy the code
pet is Fish
Is a type predicate. The predicate forparameterName is Type
In this form,parameterName
Must be an argument name from the current function signature, and the return value of the function must beboolean
Type.
Of these, custom type protection is the most common. We can look at the following code:
const onClick = (e: MouseEvent) = > {
const scrollLeft = (e.target as Element).scrollLeft
const scrollTop = (e.target as Element).scrollTop
}
Copy the code
Try type protection again:
// We can do nothing, just type judgment
function isElement(target: EventTarget) :target is Element {
return true
}
const onClick = (e: MouseEvent) = > {
// this time e.target is EventTarget
if (isElement(e.target)) {
// this time e.target is Element
const scrollLeft = e.target.scrollLeft
const scrollTop = e.target.scrollTop
}
}
Copy the code
As you can see, using type protection helps us optimize many responsible type assertions.
Template string type
This type comes with typescirpt after version 4.1 and comes with features that greatly simplify typescirpt writing:
type World = "world";
type Greeting = `hello ${World}`;
// Same as type Greeting = "Hello world", similar to es6 template strings
Copy the code
In addition, the string template type automatically helps us break down union types by writing type definitions like the following:
type XDirection = 'left' | 'right'
type YDirection = 'top' | 'bottom'
type Direction = `${YDirection}-${XDirection}`
/ / equivalent type Direction = "top - left" | "top - right" | "bottom - left" | "bottom - right"
Copy the code
Of course, you can also write:
type Prefix<T extends string> = `${T}The ${string}` // The template can contain any type related to strings
const foo1: Prefix<'foo-'> = 'foo-1'
const foo2: Prefix<'foo-'> = 'foo-2'
Copy the code
The Prefix utility function above allows us to create any string type that starts with a generic parameter using the string template type, which can be used to satisfy many types in the real world.
Interestingly, string types can also be used with the infer keyword. We can separate substrings from string types that conform to a certain rule:
// We can deconstruct the matching template format
type Tool<T> = T extends `${infer P}.${infer U}`? `${P}+${U}`:never
// "a+b"
type Test1 = Tool<`a.b`>
// 'a+${number}' is available only in Typescript 4.3, not in version 4.1
type Test2 = Tool<`a.The ${number}`>
Copy the code
Typescript version 4.3 has improved the string template type to do a lot of complex and interesting things. If you’re interested, check out the documentation.
Improve paper
This article is a personal look at some usage scenarios in Typescript.
How do YOU define a type
Interface and Type
Here’s my personal opinion: use interface to describe data structures and type to describe type relationships.
-
First of all, semantically speaking, the interface interface is obvious and acts as a constraint on the objects we define. The type alias, on the other hand, is more of a naming function, that is, it’s not really a constraint function per se, it’s just a name change that simplifies the way we write types.
-
Furthermore, as TypeScript puts it, interfaces can only define the structure of object (function) types and can be inherited. The structure that type can define and the way it can be defined (for example, type Foo = typeof Foo) are more numerous and cannot inherit from other types, but can be simulated using the ampersand type.
-
Then, from the perspective of mutable and immutable, interface is mutable because of type combination, while type cannot be modified again after definition, and is immutable. The specific introduction has been given in detail in the last section, so I will not repeat it here.
As for who to use in business, the recommendation in TSLint is to give preference to interface when both interface and Type are available. In my opinion, in real life scenarios, as long as the style is consistent with the team, interface/type is not necessary except for certain scenarios (those we mentioned above).
One more tip: When you don’t know how you should define a type, use type instead.
Define subtypes appropriately
That’s the idea of modularity. Think of write definitions as write component modules.
Something like this:
interface ComponentSize = 'small' | 'default' | 'large'
interface ComponentType = 'primary' | 'warnning' | 'danger'
interface ButtonProps {
size: ComponentSize
type: ComponentType
}
Copy the code
Improve code cleanliness by separating out subtypes that might be used in multiple types.
Use defined types
In real development we could do this:
import React from 'react'
// Define the types of props everywhere
export interface ButtonProps {
onClick: (e: React.MouseEvent) = > void
}
const Button: React.FC<ButtonProps> = ({ children, onClick }) = > {
return <button onClick={onClick}>{children}</button>
}
export default Button
Copy the code
import React from 'react'
import Button, { ButtonProps } from './button'
const Demo: React.FC = () = > {
// This is a quick way to use defined types
const onClick: ButtonProps['onClick'] = () = > {}
return <Button onClick={onClick} />
}
Copy the code
As you can see, we can get the onClick type directly from the existing ButtonProps type, and in some modules with strong business dependencies it is even better than using subtypes.
A case where the data is written before the type is written
A lot of people think that if you use typescript, you must write the types and then constrain the variables. Yes, this is true in most cases, but for some special cases, writing the type first is a bit redundant and makes our code redundant.
In this case, we can define the type as follows:
const obj = {
a: 1.b: 2
}
// type
type Obj = typeof obj
Copy the code
So when do we generate a type like this? Here’s an example:
In actual development I came across a requirement to encapsulate all request functions in an API folder and pass throughrequire.context
(webpack) orimport.meta.glob
Methods such as (vite) scan folders and encapsulate all specified request methods into an object exported for easy invocation.As some of you might have guessed, in this case, maybe we could manually define the interface that contains all the requesting functions, but as we increase the number of requesting functions, the benefits of doing so are low, and we need toDynamically generate a type that combines all the Api’s type declarations together.
For me, I usually write a plugin in development to help us automatically generate type interfaces:
// Generate the definition file based on the absolute path of the file
export function generateDeclaration(files: string[]) :string {
const filesImport: string[] = []
return `${files .filter((file) => ! file.endsWith('.d.ts'))
.map((file, index) => {
const strArr = file.split('. ')
const len = strArr.length
const ImportName = `File_${index}`
filesImport.push(ImportName)
return `import * as ${ImportName} from '${strArr
.slice(0, len > 1 ? len - 1 : len)
.join('. ')}'`
})
.join('\n')}
export type Api = ${filesImport
.map((fileImport) => `typeof ${fileImport}`)
.join('&')}
`
}
Copy the code
The fs module listens to the directory during development and automatically generates the type and writes it to the specified type file every time the file is modified:
// The following is an export
import { Api } from 'xxx'
const files = require.context('. '.true./\.ts$/)
const $api: Api = {} as Api
files.keys().forEach((key) = > {
const fileModule = files(key)
const moduleKeys = Object.keys(fileModule) as (keyof Api)[]
moduleKeys.forEach((moduleKey) = > {
$api[moduleKey] = fileModule[moduleKey]
})
})
export default $api
Copy the code
A little engineering meaning ~
However, in daily development, it is recommended to write the type first if you can, and to use typeof Value only when it would be less profitable to write the type than to write the data first (which is rare).
When to use type inference
An intuitive approach. If you’re using a typescript-friendly editor like VSCode, the auto-recognition feature of the editor is a great option. If you declare a variable that has an explicit type in the editor, you don’t need to declare the type manually, unless the type is any or does not match the type you want.
The role of generics in type inference
As mentioned earlier, generics are the soul of typescript’s inferred types. I don’t know how you usually define and use generics, but in many cases we don’t need to pass in generics manually, relying on their automatic inference of the type of variable we can get most of the type definition ideas. Let’s look at some examples from easy to difficult:
function foo<T> (bar: T) :T {
return bar
}
Copy the code
This is the simplest use of type inference for generics. We don’t have to pass in any generic arguments when we call a function, typescript automatically infer the type for us.
, of course, to use the generic automatically infer that need to satisfy some conditions, we’ve said before, functions, classes and interfaces you can use generics, in addition to the interface was introduced into parameters must be on the inside (unless wrote a default value), the class just in the constructor parameters, the function can be specified in the parameters of the.
As above, we don’t have to pass in the type of T, because we write T in the argument that it will be derived from the value of bar.
In addition to specifying the type at the parameter, we can omit the type designation by other generics inference:
function foo<
T extends Record<string.any>,
R extends T & {
test: string
}
>(bar: T): R {
return {
...bar,
b: 2,}as R
}
// Return objects with a and b
foo({ a: 1 })
Copy the code
This way, we can also automatically infer the type of the generic (of course, we must use type assertions when using the generic inside the function).
Note, however, that typescript can’t infer the following:
// No second inference can be made in extends
function foo<T extends (. args: P) = >R.P extends any[].R> (
bar: T,
// The P inference actually depends on this parameter. args: P) {
return {
params: args,
return: bar(... args), } }// { params:[number], return: unknown }
foo((a: number) = > a, 1)
Copy the code
That’s not true, but can’t we get the parameters and return values of the function we pass in? There are obviously other methods, and we can introduce infer to help us deduce:
function foo<T extends (. args:any[]) = >any> (bar: T, ... args: Textends(... args: infer P) =>any ? P : never
) :{
params: T extends(... args: infer P) =>any ? P : never
return: T extends(... args:any) => infer R ? R : any
} {
return {
params: args,
return: bar(... args) } }// { params:[a:number], return: number }
foo((a: number) = > a, 1)
Copy the code
Tedious, right? That’s why we need tool types to simplify things. Using typescript’s built-in utility types looks much cleaner:
function foo<T extends (. args:any[]) = >any> (bar: T, ... args: Parameters
) :{
params: Parameters<T>
return: ReturnType<T>
} {
return {
params: args,
return: bar(... args), } }// { params:[a:number], return: number }
foo((a: number) = > a, 1)
Copy the code
Type narrowing of the union type
Union types are more widely used than cross (&) types. As we all know, interaction types is a few different types of merged together into a new type, the type contains all types of properties, before and the joint type (|) said a value can be one of several types.
However, what you may not know is that union types can also give developers very precise type selection (or optional type narrowing) :
To use this capability, several different union types need to have at least one same key, and the type of the key name needs to be specified
interface TypeA {
type: 'a'
a: number
}
interface TypeB {
type: 'b'
b: number
}
interface TypeC {
type: 'c'
c: number
}
type UType = TypeA | TypeB | TypeC
function foo(obj: UType) {}
foo({
type: 'a'
})
Copy the code
As we can see,When we givetype
TypeScript automatically reduces the scope of a type when it is a distinguishable value.Based on such features, we can build a system based on atype
Different type parameters with different complex configurations.
Definitions of global and external module types
In your daily development, you will inevitably encounter situations that add global types or add type definitions for a library. Here’s how to add a definition to them.
Global definitions
I know of two main global definition schemes:
- One can only be defined in one without
import
orexport
keywordts
In the file (because if there areimport
orexport
Will be treated as a module) :
// global.d.ts
interface Test {
example: string
}
// Of course, the declaration of global variables is also possible
declare const test: Test
Copy the code
Or use interface’s type merge function to extend existing global variables.
interface Window {
example: string
}
Copy the code
This adds an Example attribute to the global Window type.
- There is also a method called global scoping, which must be nested in an external module or in an environment module declaration. This method is defined as follows:
// Must be used in modules, i.e. with import and export keywords
// ...
// The code that defines this sentence is already in the global context
declare global {
// Note that there is no import/export in this environment, the default is global environment
interface Test {
example: string
}
const test: Test
}
// ...
Copy the code
This is actually a bit of a stretch, I think, because many times we define global types that reference other modules’ type definitions, and we usually define global variables this way when we distribute NPM packages.
External module definition
External module definitions are often used to write or modify definition files for third-party packages, or to enable the typescript compiler to recognize file suffixes. The general writing is:
declare module 'xxx' {
// Write detailed module definitions
export interface XXX {}
export const x
}
/ / or
// Short for external module definition. Default export value is any
declare module 'xxx'
Copy the code
We can use the module declaration wildcard (*) to match certain types of modules:
For example, we want typescript to recognize CSS files
declare module '*.css'
Copy the code
With this declaration, all CSS files that end in.css are recognized by typescript.
Modify the type definition of third-party packages
Changing the definition for third-party packages is a simple matter of taking advantage of external module definitions and interface’s declarative merging capabilities. The following uses the react-redux definition file as an example:
Create a react-redux.d.ts file in your own project.
// react-redux.d.ts
// It is better to add this sentence, otherwise the export may be overwritten, only DefaultRootState exists
export * from 'react-redux'
declare module "react-redux" {
// DefaultRootState was defined as {}, we changed it to an index type
export interface DefaultRootState {
test:Test
[key: string] :any}}Copy the code
If we do not do type extension, we must manually specify the type of state when we use useSelector to get the global state of Redux, otherwise we will get a type error. This extends the type of state globally without having to add arguments to each call to get the type definition.
practice
This is a hands-on section on type-oriented programming, where you’ll learn more about Typescript.
Gets the type of the KTH argument to the function
Now we will create a utility function that returns the type of any argument position of a function, like the following:
function foo(a: number, b: string) {}
// number
type A = ParamsTool<typeof foo, 0>
// string
type B = ParamsTool<typeof foo, 1>
Copy the code
In fact, the writing method is similar to the source code of Parameters writing, but in a form, with Parameters writing is like this:
function foo(a: number, b: string) {}
// number
type A = Parameters<typeof foo>[0]
// string
type B = Parameters<typeof foo>[1]
Copy the code
Please refer to 👇🏻
// Parameters
[K] directly is also possible, but you need to limit the type of F
type ParamsTool<F, K extends number> = F extends(... args: infer P) =>any
? P[K]
: never
function foo(a: number, b: string) {}
// number
type A = ParamsTool<typeof foo, 0>
// string
type B = ParamsTool<typeof foo, 1>
Copy the code
Let me write a ref for Vue3
Ref is provided for us in Vue3, which can provide us with a responsive carrier for a certain value. Here is a brief look at its type and how to use it:
// The object generated by the ref function
export interface Ref<T = any> {
value: T
/** * Type differentiator only. * We need this to be in public d.ts but don't want it to show up in IDE * autocomplete, so we use a private Symbol instead. */
[RefSymbol]: true
/ * * *@internal* /_shallow? :boolean
}
Copy the code
/ / value
const test1 = ref(0) // Ref<number>
// a Ref wrapper
const test2 = ref(test1) // Ref<number>
// double Ref wrap
const test3 = ref(test2) // Ref<number>
// Pass in the object
const test4 = ref({
test: 0,
test1,
test2,
test3
})
/* Ref<{ test: number; test1: number; test2: number; test3: number; } > * /
// Pass in an array (tuple)
const test5 = ref([
0,
test1,
test2,
test3,
test4,
{
test: 0,
test1,
test2,
test3
}
] as const)
/* Ref
*/
Copy the code
As you can see, no matter we are incoming values or ref object, finally a solution package processing, get the real value of the type, there will be no nested ref type, and in the incoming objects and arrays, will make some special operations, such as the incoming object when all ref object directly to unpack on the object, When you pass in an array, you iterate over the elements of the array and make a separate judgment. Now we need to write the definition of the ref function so that it meets our expectations.
Infer can be used to determine whether the type is Ref type by defining a type that can be recursively. For the operations related to arrays and objects, the type can be determined separately by using conditional types.
Please refer to 👇🏻
This answer is not the real ref type definition, but also refer to the ref source code definition, students who want to see the source code can directly access the ref source view.
The // ref object definition simply considers value
export interface Ref<T = any> {
value: T
}
export function ref<T> (value: T) :Ref<UnwrapRef<T>>
Copy the code
As you can see, we finally have a tool type for UnwrapRef that will help us unpack the ref.
export type UnwrapRef<T> = T extends Ref<infer V>
? UnwrapRef<V>
: T
Copy the code
Are we done? Of course not. Now we’ve just done the basic unpacking, but remember we still need to determine if the parameters passed in are objects and arrays and operate on them separately, so we need to extract one more type separately:
// Separate extract
type UnwrapRefSimple<T> = T extends Array<any>
// If it is an array, recurse all elements
? { [K in keyof T]: UnwrapRefSimple<T[K]> }
: // Check whether it is a function or Ref (this is entered when the Ref object is in the array)
T extends Function | Ref
// If it is a function or Ref, return it as is
? T
// If it is an object, unpack all attributes in the object
: T extends object
? { [P in keyof T]: UnwrapRef<T[P]> }
: T
Copy the code
OK, plus the definition of ref function, complete implementation is as follows:
The // ref object definition simply considers value
export interface Ref<T = any> {
value: T
}
// Separate extract
type UnwrapRefSimple<T> = T extends Array<any>
// If it is an array, recurse all elements
? { [K in keyof T]: UnwrapRefSimple<T[K]> }
: // Check whether it is a function or Ref (this is entered when the Ref object is in the array)
T extends Function | Ref
// If it is a function or Ref, return it as is
? T
// If it is an object, unpack all attributes in the object
: T extends object
? { [P in keyof T]: UnwrapRef<T[P]> }
: T
export type UnwrapRef<T> = T extends Ref<infer V>
? UnwrapRefSimple<V>
: UnwrapRefSimple<T>
export declare function ref<T> (value: T) :Ref<UnwrapRef<T>>
Copy the code
Write a createLocalStorage bai
This function needs to implement such an effect that the type definition automatically helps us return objects with different attributes when different arguments are passed:This function is implemented as follows:
export interfacecreateLocalStorageOptions<V> { map? :(v: string | null) = >V defaultValue? : V }/ / encapsulation localStorage
// We need to automatically infer the returned object properties from the name passed in
export function createLocalStorage<T extends string.V = string | null> (name: T, options: createLocalStorageOptions
= {}
) {
const { defaultValue, map = (v: string | null) = > v } = options
const action = name[0].toUpperCase() + name.slice(1)
const setAction = `set${action}`
const getAction = `get${action}`
const removeAction = `remove${action}`
const key = `${name}-key`
return {
[setAction](value: string) {
localStorage.setItem(key, value)
},
[getAction]() {
return map(localStorage.getItem(key)) ?? defaultValue ?? null
},
[removeAction]() {
localStorage.removeItem(key)
},
}
}
Copy the code
Now that the return value of our function is an index type, we can think about how to improve it internally.
We’ve already mentioned typescript’s native tool types for changing the case of strings, plus conditional types that type different functions separately.
Please refer to 👇🏻
export interfacecreateLocalStorageOptions<V> { map? :(v: string | null) = >V defaultValue? : V }export function createLocalStorage<T extends string.V = string | null> (name: T, options: createLocalStorageOptions
= {}
) {
const { defaultValue, map = (v: string | null) = > v } = options
// Modify the type string
type SetAction = `set${Capitalize<T>}`
type GetAction = `get${Capitalize<T>}`
type RemoveAction = `remove${Capitalize<T>}`
const action = name[0].toUpperCase() + name.slice(1)
const setAction = `set${action}`
const getAction = `get${action}`
const removeAction = `remove${action}`
const key = `local-storage-${name}-key`
return {
[setAction](value: string) {
localStorage.setItem(key, value)
},
[getAction]() {
return map(localStorage.getItem(key)) ?? defaultValue ?? null
},
[removeAction]() {
localStorage.removeItem(key)
},
} as {
// Take each child of the union type, then use the condition type to judge separately
[P in SetAction | GetAction | RemoveAction]: P extends SetAction
? (value: string) = > void
: P extends GetAction
? () = > V
: () = > void}}Copy the code
Write a setState with useState
Use Estate in React is familiar, but its behavior differs from that of the React ClassComponent’s setState behavior, so we’ll encapsulate it so that it behaves like setState.
import { useState, useCallback } from 'react'
function useSetState<S extends Record<string.any> > (initial: S | (() => S)) {
const [state, setState] = useState(initial)
const setMergeState: SetMergeStateTool<typeof setState> = useCallback(
(currentState) = > {
if (typeof currentState === 'function') {
setState((prevState) = >({... prevState, ... currentState(prevState) })) }else {
setState((prevState) = >({... prevState, ... currentState })) } }, [] )return [state, setMergeState] as const
}
export default useSetState
Copy the code
Ok, the encapsulation method is simple, but the type writing is interesting, what we’re going to do now is just use a type tool to write our current setMergeState type based on the original setState type, how do you do that?
For a function, the most important definition is the parameter and the return value. If we can take these two things, and then modify the mapping of them, we can get the desired answer.
Please refer to 👇🏻
// If all attributes of the passed object become optional
type ObjectKeysPartial<T> = T extends(... args:any[]) = >any ? T : Partial<T>
// If you pass a function, make all the attributes of the object returned by the function optional
type FunctionReturnPartial<T> = T extends(... args: infer P) => infer R ?(. args: P) = > ObjectKeysPartial<R>
: T
// setState can pass objects and functions, so we need to check whether the setState function is modified, while the setState object is modified
type setMergeStateParamsTool<T> = {
[K in keyof T]: ObjectKeysPartial<FunctionReturnPartial<T[K]>>
}
// Modify parameters and return values
type SetMergeStateTool<T> = T extends(... args: infer P) => infer R ?(. args: setMergeStateParamsTool) = > R
: T
Copy the code
Type inference for dispatch in VUEX
What we want to achieve is that when we call Dispatch, it will automatically give us a hint of the type of the action that can match, including parsing out the type of modules in VUex
In the vuex source code we can roughly extract its type:
The following code can be briefly reviewed and will be refactored later
interfaceDispatchOptions { root? :boolean
}
interface Payload {
type: string
}
interface Dispatch {
// Type is a string
(type: string, payload? :any, options? : DispatchOptions):Promise<any> <P extends Payload>( payloadWithType: P, options? : DispatchOptions ): Promise<any> } interface ActionTree<S, R> { [key: string]: Action<S, R>} // ActionHandler and ActionObject will not be discussed much, Generally is to limit the action of value type action < S, R > = ActionHandler < S, R > | ActionObject < S, R > interface ModuleTree < R > {[key: string] : Module<any, R>} interface Module<S, R> {// We can use namespaced interface Module<S, R> {// We can use namespaced interface Module<S, R> { : boolean state? : S | (() => S) // ... // modules can be nested modules? : ModuleTree<R>} interface StoreOptions<S> { : S | (() => S) // ... actions? : ActionTree<S, S> modules? : ModuleTree<S> // .. Constructor (options: StoreOptions<S>) {} Dispatch: dispatch //... } // Use const store = new store ({actions: {test() {console.log('test')}}}) store.dispatch('test')Copy the code
However, as we can see, the Dispatch itself has no indication of type, and we now need to optimize the Store’s type definition so that it can be inferred automatically.
Using a string template type, and since vuex modules can be nested, we need to create a type specific recursive type to parse modules’ types, as well as determine if namespaced is true.
Please refer to 👇🏻
We focus on the action type derivation, so the following code is only related to action and modules, if you want to write full vuex definition of friends can expand.
This type of writing is more complex, the following slowly explained:
- First we need to redefine the generic parameters of the related type.
// Simplify internal operations by treating actions as ordinary functions type Action = () = > void // Actions class definition interface ActionTree { [key: string]: Action } // Type definition of modules interface ModuleTree { [key: string]: Module } interfaceDispatchOptions { root? :boolean } // The function overrides the type qualification for the second mode interface Payload<A> { type: A } // Do not write extends to define generic parameters. This limits the inferred type // Attributes of the module interface Module<N = boolean, A = ActionTree, M = ModuleTree> { // We can simplify the operation, as long as the open can be nested (real case can also have many special cases)namespaced? : N actions? : AModules can be nestedmodules? : M }interface Dispatch<T> { // Specify the type of type here (type: T, payload? :any, options? : DispatchOptions):Promise<any> / / overloaded<P extends Payload<T>>( payloadWithType: P, options? : DispatchOptions ): Promise<any>} Constructor argument interface StoreOptions<A extends ActionTree, M extends ModuleTree> {actions? : A modules? : M} export Declare Class Store<A extends ActionTree, M extends ModuleTree> {constructor(options) StoreOptions<A, M>) dispatch: Dispatch<keyof A | GetModulesType<M>> // ... }Copy the code
OK, now that we have modified some of the existing types, we need to add our recursive types to parse
modules
To support thetype
See the topDispatch
Okay? The generic argument passed to it is all we can deducetype
First we usekeyof
Get the outermost layeractions
thetype
Type, and then we define a new typeGetModulesType
Judging by the name, we rely on him to parse itmodules
The type of. - Now let’s define our
GetModulesType
:/ / traverse ModuleTree type GetModulesType<MS extends ModuleTree> = { // Here we take an index inside (as a prefix) and pass it into the tool that gets the type separately [K in keyof MS]: GetModuleType<K, MS[K]> }[keyof MS] // iterate over all values // Recursively get all types associated with the Module type GetModuleType<P, M> = M extends Module<infer N, infer A, infer SubModules> // N is the namespaced value, A is the module's actions value (ActionTree), and SubModules is the module's value ? /* N extends true. Since N is Boolean, N is a combination of true and false, so both are raised. Our N is restricted to either true or false */ N extends true ? // If namespaced is true, we return the actions with a prefix and all the child modules' actions, otherwise we don't prefix them GetPrefixActionKeys<P, A> | GetPrefixSubModules<P, SubModules> : GetActionKeys<A> | GetSubModules<SubModules> : never Copy the code
After the above conversion, we can index the object’s
modules
(ModuleTree
) to a singleModule
And go through all of them againModule
Here we generate four new typesGetPrefixActionKeys
,GetPrefixSubModules
,GetActionKeys
andGetSubModules
, which, as the name suggests, handles the current module separatelyactions
andmodules
. - Finally, let’s complete the above four types:
// Utility functions // Because we are passing keyof, but we only want string, so we can just take string type Prefix<P, K> = `${P & string}/${K & string}` // Get prefixed submodules Since our options are optional, we need to add two more judgments, pass the object, and the object has an index type GetPrefixSubModules<P, SM> = SM extends ModuleTree ? keyof SM extends string ? // This is where the recursion begins, passing submodules as modules Prefix<P, GetModulesType<SM>> : never : never // Get submodules without prefixes type GetSubModules<SM> = SM extends ModuleTree ? keyof SM extends string ? // Start recursion GetModulesType<SM> : never : never // Get the prefixed actions keys Since our options are optional, we need to add two more judgments, pass the object, and the object has an index type GetPrefixActionKeys<P, A> = A extends ActionTree ? keyof A extends string ? Prefix<P, keyof A> : never : never // Get the keys of actions without a prefix type GetActionKeys<A> = A extends ActionTree ? keyof A extends string ? keyof A : never : never Copy the code
We’re done. In
GetPrefixSubModules
andGetSubModules
Use recursion to convert to the moduletype
Get, and finally get the type.
Take a test:
// test
const store = new Store({
actions: {
root(){}},modules: {
cart: {
namespaced: true.actions: {
add() {},
remove(){}}},user: {
namespaced: true.actions: {
login(){}},modules: {
admin: {
namespaced: true.actions: {
adminLogin(){}},modules: {
goods: {
/ / namespaced to false
namespaced: false.actions: {
add(){}}}}},editor: {
namespaced: true.actions: {
editorLogin(){}},modules: {
post: {
/ / don't write namespaced
actions: {
create() {}
}
}
}
}
}
}
}
})
store.dispatch('root')
Copy the code
Perfect!
Complete code:
// Utility functions
// Because we are passing keyof, but we only want string, so we can just take string
type Prefix<P, K> = `${P & string}/${K & string}`
// Get prefixed submodules
Since our options are optional, we need to add two more judgments, pass the object, and the object has an index
type GetPrefixSubModules<P, SM> = SM extends ModuleTree
? keyof SM extends string
? // This is where the recursion begins, passing submodules as modules
Prefix<P, GetModulesType<SM>>
: never
: never
// Get submodules without prefixes
type GetSubModules<SM> = SM extends ModuleTree
? keyof SM extends string
? // Start recursion
GetModulesType<SM>
: never
: never
// Get the prefixed actions keys
Since our options are optional, we need to add two more judgments, pass the object, and the object has an index
type GetPrefixActionKeys<P, A> = A extends ActionTree
? keyof A extends string
? Prefix<P, keyof A>
: never
: never
// Get the keys of actions without a prefix
type GetActionKeys<A> = A extends ActionTree
? keyof A extends string
? keyof A
: never
: never
// Simplify internal operations by treating actions as ordinary functions
type Action = () = > void
interface ActionTree {
[key: string]: Action
}
interface ModuleTree {
[key: string]: Module
}
// Do not write extends here. It limits the type of inference involved
interface Module<N = boolean, A = ActionTree, M = ModuleTree> {
// We can simplify the operation, as long as the open can be nested (real case can also have many special cases)namespaced? : N actions? : AModules can be nestedmodules? : M }/ / traverse ModuleTree
type GetModulesType<MS extends ModuleTree> = {
// Here we take an index inside (as a prefix) and pass it into the tool that gets the type separately
[K in keyof MS]: GetModuleType<K, MS[K]>
}[keyof MS] // iterate over all values
// Recursively get all types associated with the Module
type GetModuleType<P, M> = M extends Module<infer N, infer A, infer SubModules> // N is the namespaced value, A is the module's actions value (ActionTree), and SubModules is the module's value
? /* N extends true. Since N is Boolean, N is a combination of true and false, so both are raised. Our N is restricted to either true or false */
N extends true
? // If namespaced is true, we return the actions with a prefix and all the child modules' actions, otherwise we don't prefix them
GetPrefixActionKeys<P, A> | GetPrefixSubModules<P, SubModules>
: GetActionKeys<A> | GetSubModules<SubModules>
: never
interface StoreOptions<A extends ActionTree, M extendsModuleTree> { actions? : A modules? : M }interfaceDispatchOptions { root? :boolean
}
interface Payload<A> {
type: A
}
interface Dispatch<T> {
(type: T, payload? :any, options? : DispatchOptions):Promise<any> <P extends Payload<T>>( payloadWithType: P, options? : DispatchOptions ): Promise<any>} export Declare Class Store<A extends ActionTree, M extends ModuleTree> { Do not speak code implements the constructor (options: StoreOptions < > A, M) dispatch: dispatch < keyof A | GetModulesType < M > > / /... } // test const store = new Store({ actions: { root() {} }, modules: { cart: { namespaced: true, actions: { add() {}, remove() {} } }, user: { namespaced: true, actions: { login() {} }, modules: { admin: { namespaced: // Namespaced: false, actions: {adminLogin() {}}, modules: {goods: {// namespaced: false, actions: { add() {} } } } }, editor: { namespaced: true, actions: { editorLogin() {} }, modules: { post: {// don't write namespaced actions: {create() {}}}}}}}}}) store.dispatch('root')Copy the code
conclusion
The length of this article is very long, many places can be isolated as an article, but I feel it is not conducive to the later collation and reference, so I wrote together, of course, if you feel that the viewing effect is not very good, you can also put forward, later will make improvements.
Again, this article is just a summary of some of the personal practices I’ve learned in typescript. It’s not necessarily correct, and it doesn’t introduce too many complex types, but typescript is certainly capable of much more than that. There are many examples of heavily typescript typed programming. However, I hope this article has helped you get a feel for how typescript works in your projects. If you can work your way through a few of the typescript type definitions in this article, you’ll be able to improve your typescript capabilities.
Finally, this paper will inevitably have some summary is not in place, please point out or supplement in time, the author will be corrected in the first time. If you find this article helpful, please visit 👍🏻.
reference
- TypeScript official documentation
- Ref definition of Vue
- Vuex definition file
- A new feature in TS 4.1 enables Dispatch type inference for Vuex infinite hierarchy namespaces