content
- type metaprogramming
- Built-in tool type snooping
- External tool type recommendation
- new operator
- Declaration file
type metaprogramming
What is metaprogramming:
Wikipedia describes it this way: Metaprogramming is a programming technique in which computer programs are written to process other programs as data. It means that programs can be written that can read, generate, analyze, or transform other programs, and even modify programs themselves at runtime. In some cases, this allows programmers to minimize the number of lines of code to express a solution, thereby reducing development time. It also allows programs to handle new situations more flexibly and efficiently without recompilation.
Simply put, metaprogramming can write code like this:
- can generate code
- Language constructs can be modified at runtime, a phenomenon known as Reflective Metaprogramming or Reflection
What is reflection:
Reflection is a branch of metaprogramming, which has three sub-branches:
- Introspection: The code can inspect itself and access internal properties, from which we can obtain low-level information about the code.
- Self-Modification: As the name suggests, code can modify itself.
- Intercession: Literally means "acting on behalf of others." In metaprogramming, the concept of intercession is similar to wrapping, trapping, and intercepting.
Give a practical example
- ES6 (ECMAScript 2015) uses Reflect (implementing introspection) and Proxy (implementing mediation) for coding operations, which is called a kind of metaprogramming.
- Before ES6, eval was used to generate additional code, Object.defineProperty was used to change the semantics of an object, etc.
Type Metaprogramming with TypeScript
Personally, I feel that the concept of "metaprogramming" does not have a standard and clear definition, so this article uses keywords such as infer, keyof, in, etc. in TypeScript to operate, which is called TypeScript's type metaprogramming. In other words, it is a "lower-level feature" or "saucy operation", and everyone can understand its purpose.
unknown
unknown type is Top Type in TypeScript. The notation is (⊤), in other words, any type is a subtype of unknown, which is the supertype of all types. In other words, in the simplest terms, any value can be assigned to a variable of type unknown, and correspondingly, we cannot assign a value of unknown type to any value of non-unknown type.
let a: unknown = undefined
a = Symbol('deep dark fantasy')
a = {}
a = false
a = '114514'
a = 1919n
let b : bigint = a; // Type 'unknown' is not assignable to type 'bigint'.
never
The behavior of never is the opposite of unknown, never is Bottom Type in TypeScript, the symbol is (⊥), in other words, any type is the supertype of never, and never is the subtype of all types.
As the name suggests, it means "never" => "don't". The combination of never and infer is a common gymnastics posture, which will be introduced below.
let a: never = undefined // Type 'undefined' is not assignable to type 'never'
keyof
Can be used to get all keys of types such as objects or arrays, and return a union type
interface Person {
name: string
age: number
}
type K1 = keyof Person // "name" | "age"
type K2 = keyof [] // "length" | "toString" | "push" | "concat" | "join"
type K3 = keyof { [x: string]: Person } // string | number
in
In map type it is possible to traverse union types
type Keys = 'firstName' | 'lastName'
type Person = {
[key in Keys]: string
}
// Person: { firstName: string; lastName: string; }
[]
Index operator, use the [] operator to perform index access. The so-called index is to return the corresponding value according to a certain point. For example, the index of an array is subscript 0, 1, 2, etc. There are two types of index signatures in TypeScript: string indexing and numeric indexing.
string index (object)
For pure object types, use string indexing, syntax: T[key]
interface Person {
name: string
age: number
}
type Name = Person['name'] // Name: string
The index type is itself a type, so you can also use union types or other types to operate
type I1 = Person['name' | 'age'] // I1: string | number
type I2 = Person[keyof Person] // I2: string | number
numeric index (array)
For array-like types, use numeric indexing, syntax: T[number]
type MyArray = ['Alice', 'Bob', 'Eve']
type Alice = MyArray[0] // 'Alice'
type Names = MyArray[number] // 'Alice' | 'Bob' | 'Eve'
Practical example
const PLAYS = [
{
value: 'DEFAULT',
name: '支付送',
desc: '用户支付后即获赠一张券',
},
{
value: 'DELIVERY_FULL_AMOUNT',
name: '满额送',
desc: '用户支付满一定金额可获赠一张券',
checkPermission: true,
permissionName: 'fullAmount',
},
]
type Play = typeof PLAYS[number]
/*
type Play = {
value: string;
name: string;
desc: string;
checkPermission?: undefined;
permissionName?: undefined;
} | {
value: string;
name: string;
desc: string;
checkPermission: boolean;
permissionName: string;
}
*/
generic
In software engineering, we not only create consistent well-defined APIs, but also consider reusability. Components can support not only current data types, but also future data types, which is useful when creating large systems.
The actual example, encapsulates the ajax request library, supports different interfaces to return the data structure it should have.
function ajax<T>(options: AjaxOptions): Promise<T> {
// actual logic...
}
function queryAgencyRole() {
return ajax<{ isAgencyRole: boolean }>({
method: 'GET',
url: '/activity/isAgencyRole.json',
})
}
function queryActivityDetail() {
return ajax<{ brandName: string; }>({
method: 'GET',
url: '/activity/activityDetail.json',
})
}
const r1 = await queryAgencyRole()
r1.isAgencyRole // r1 里可以拿到 isAgencyRole
const r2 = await queryActivityDetail()
r2.brandName // r2 里可以拿到 brandName
extends
In the official definition, it is called Conditional Types, which can be understood as "ternary operation", T extends U ? X : Y, if T is a subset of U, then return X, otherwise return Y.
- Generally used in conjunction with generics.
- extends will traverse the union type and return the union type.
type OnlyNumber<T> = T extends number ? T : never
type N = OnlyNumber<1 | 2 | true | 'a' | 'b'> // 1 | 2
Usually, the distributed union type is what we want, but it is also possible for extends to not traverse the union type, but to judge and return as a whole. Just add square brackets [] to the left and right sides of the extends keyword to modify it.
// 分布的条件类型
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;
// type R = string[] | number[]
// 不分布的条件类型
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type R = ToArrayNonDist<string | number>;
// type R = (string | number)[]
infer
The infer keyword can store the type during the operation, similar to defining a variable.
The built-in tool type ReturnType is implemented based on this feature.
type ReturnType<T> = T extends (...args: any) => infer R ? R : any;
type R1 = ReturnType<() => number> // R1: number
type R2 = ReturnType<() => boolean[]> // R2: boolean[]
recursion
Recursion in TypeScript also calls (or references) itself, but doesn't necessarily need to jump out.
As follows, the standard type structure of JSON objects is defined.
// 定义基础类型集
type Primitive = string | number | boolean | null | undefined | bigint | symbol
// 定义 JSON 值
type JSONValue = Primitive | JSONObject | JSONArray
// 定义以纯对象开始的 JSON 类型
interface JSONObject {
[key: string]: JSONValue
}
// 定义以数组开始的 JSON 类型
type JSONArray = Array<JSONValue>
Just a quick question: Why doesn't TypeScript break out of recursion or get stuck in an infinite loop?
But apart from being computationally intensive, these types can hit an internal recursion depth limit on sufficiently-complex inputs. When that recursion limit is hit, that results in a compile-time error. In general, it’s better not to use these types at all than to write something that fails on more realistic examples.
--from https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#recursive-conditional-types
typeof
Concept: Modern statically typed languages like TypeScript generally have two "spaces" for placing language entities, namely the type-level space and the value-level space. The former is used to store the types in the code. Information, which is completely erased at runtime; the latter is used to store "values" in the code and is retained until runtime.
- Value space: variables, objects, arrays, classes, enums, etc.
- Type space: type, interface, class, enum, etc.
The role of typeof is to convert the data in the "value space" into the data in the "type space".
const MARKETING_TYPE = {
ISV: 'ISV_FOR_MERCHANT',
ISV_SELF: 'ISV_SELF',
MERCHANT: 'MERCHANT_SELF',
}
type MarketingType = typeof MARKETING_TYPE
/*
type MarketingType = {
ISV: string;
ISV_SELF: string;
MERCHANT: string;
}
*/
as const
as const is a type assertion, which also converts the data in the "value space" into the data in the "type space" and sets it to read-only.
let x = 'hello' as const; // x: 'hello'
let y = [10, 20] as const; // y: readonly [10, 20]
let z = { text: 'hello' } as const; // z: { readonly text: 'hello' }
Practical example:
const MARKETING_TYPE = {
ISV: 'ISV_FOR_MERCHANT',
ISV_SELF: 'ISV_SELF',
MERCHANT: 'MERCHANT_SELF',
} as const
type MT = typeof MARKETING_TYPE
type MarketingType = MT[keyof MT]
/*
type MT = {
readonly ISV: "ISV_FOR_MERCHANT";
readonly ISV_SELF: "ISV_SELF";
readonly MERCHANT: "MERCHANT_SELF";
}
type MarketingType = "ISV_FOR_MERCHANT" | "ISV_SELF" | "MERCHANT_SELF"
*/
Built-in tool type snooping
TypeScript has built-in useful tool types that can improve the efficiency of type conversion during development.
Based on the above understanding, it is very easy to read the built-in tool types. Here we list a few commonly used or representative tool types.
Partial
Role: Make each property of the object an optional property.
interface Todo {
title: string;
description: string;
}
type NewTodo = Partial<Todo>
/*
type NewTodo = {
title?: string;
description?: string;
}
*/
Principle: Add a ? symbol to each attribute to make it optional.
type Partial<T> = {
[P in keyof T]?: T[P];
};
Required
Role: Contrary to Partial, make each property of the object a required property.
interface Todo {
title?: string;
description?: string;
}
type NewTodo = Required<Todo>
/*
type NewTodo = {
title: string;
description: string;
}
*/
Principle: Add the -? symbol to each attribute, - means to remove, -? means to remove the optional, and it becomes the required type.
type Required<T> = {
[P in keyof T]-?: T[P];
};
Readonly
Role: Turns each property of the object into a read-only property.
interface Todo {
title: string;
description: string;
}
type NewTodo = Readonly<Todo>
/*
type NewTodo = {
readonly title: string;
readonly description: string;
}
*/
const todo: Readonly<Todo> = {
title: 'Delete inactive users'
}
// Cannot assign to 'title' because it is a read-only property.
todo.title = "Hello";
Principle: Add the readonly keyword to each property, and it becomes a read-only property.
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
Pick
Function: Like lodash's pick method, select the required key value in the object to return a new object, but here the type is selected.
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, 'title' | 'completed'>
/*
type TodoPreview = {
title: string;
completed: boolean;
}
*/
Principle: Use the conditional type to constrain the incoming union type K, and then traverse the eligible union type K.
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
Omit
Effect: Contrary to the Pick tool method, exclude certain key values of the object.
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Omit<Todo, 'description'>
/*
type TodoPreview = {
title: string;
completed: boolean;
}
*/
Principle: Similar to Pick, but first get the remaining properties after exclusion through Exclude, and then traverse to generate new object types.
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Exclude
Role: Exclude some member types in the union type.
type T0 = Exclude<'a' | 'b' | 'c', 'a'> // T0: 'b' | 'c'
type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'> // T1: 'c'
Principle: Exclude unnecessary types through conditional type extends.
type Exclude<T, U> = T extends U ? never : T;
Parameters
Function: Get the parameter type of the function and return a tuple type
type T0 = Parameters<() => string> // T0: []
type T1 = Parameters<(s: string) => void> // T1: [s: string]
Principle: Get the parameter type of the function through the infer keyword and return it
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
ReturnType
Role: Get the return type of the function
type R1 = ReturnType<() => number> // R1: number
type R2 = ReturnType<() => boolean[]> // R2: boolean[]
Principle: Obtain the function return type through the infer keyword
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
Awaited
Role: Get the primitive type without Promise package.
type res = Promise<{ brandName: string }>
type R = Awaited<res> // R: { brandName: string }
Principle: If it is a common type, return the type, if it is a Promise type, use infer to define the value of then and return it.
type Awaited<T> =
T extends null | undefined
? T
: T extends object & { then(onfulfilled: infer F): any } // 检查 Promise 类型
? F extends (value: infer V, ...args: any) => any
? Awaited<V> // 递归 value 类型
: never // 不符合规则的 Promise 类型丢弃
: T; // 不是 Promise 类型直接返回
The Promise type has the following shape
/**
* Represents the completion of an asynchronous operation
*/
interface Promise<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;
/**
* Attaches a callback for only the rejection of the Promise.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of the callback.
*/
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}
Another simple implementation to get a Promise type:
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T
External tool type recommendation
There are 2 open source tool libraries with more stars on the market
type-fest: https://github.com/sindresorhus/type-fest
utility-types: https://github.com/piotrwitek/utility-types
I have never used type-fest, let me introduce the ValuesType of utility-types, which is more commonly used.
ValuesType
Get the value type of an object or array.
interface Person {
name: string
age: number
}
const array = [0, 8, 3] as const
type R1 = ValuesType<Person> // string | number
type R2 = ValuesType<typeof array> // 0 | 8 | 3
type R3 = ValuesType<[8, 7, 6]> // 8 | 7 | 6
Practical example: Get the value type of a JS constant to avoid duplication of effort.
const MARKETING_TYPE = {
ISV: 'ISV_FOR_MERCHANT',
ISV_SELF: 'ISV_SELF',
MERCHANT: 'MERCHANT_SELF',
} as const
type MarketingType = ValuesType<typeof MARKETING_TYPE>
// type MarketingType = "ISV_FOR_MERCHANT" | "ISV_SELF" | "MERCHANT_SELF"
Implementation principle: Use the "string index" and "number index" mentioned above to get the value.
type ValuesType<
T extends ReadonlyArray<any> | ArrayLike<any> | Record<any, any>
> = T extends ReadonlyArray<any>
? T[number]
: T extends ArrayLike<any>
? T[number]
: T extends object
? T[keyof T]
: never;
new operator
[2.0] Non-null assertion operator
Assert that a value exists
function createGoods(value: number): { type: string } | undefined {
if (value < 5) {
return
}
return { type: 'apple' }
}
const goods = createGoods(10)
goods.type // ERROR: Object is possibly 'undefined'. (2532)
goods!.type // ✅
[3.7] Optional Chaining
The optional chaining operator can skip the case of null and undefined, and execute the following expression only if the value exists.
let x = foo?.bar()
The result after compilation is as follows:
let x = foo === null || foo === void 0 ? void 0 : foo.bar();
Comparison of the actual scene:
// before
if (user && user.address) {
// ...
}
// after
if (user?.address) {
// ...
}
// 语法:
obj.val?.prop // 属性访问
obj.val?.[expr] // 属性访问
obj.arr?.[index] // 数组访问
obj.func?.(args) // 函数调用
[3.7] Nullish Coalescing (double question mark operator)
// before
const isBlack = params.isBlack || true // ❌
const isBlack = params.hasOwnProperty('isBlack') ? params.isBlack : true // ✅
// after
const isBlack = params.isBlack ?? true // ✅
[4.0] Short-Circuiting Assignment Operators
In JavaScript and many programming languages, they are called Compound Assignment Operators
// Addition
// a = a + b
a += b;
// Subtraction
// a = a - b
a -= b;
// Multiplication
// a = a * b
a *= b;
// Division
// a = a / b
a /= b;
// Exponentiation
// a = a ** b
a **= b;
// Left Bit Shift
// a = a << b
a <<= b;
Added:
a &&= b // a && (a = b)
a ||= b // a || (a = b)
a ??= b // a ?? (a = b)
Example:
let values: string[];
// Before
(values ?? (values = [])).push("hello");
// After
(values ??= []).push("hello");
Declaration file
It is usually understood as .d.ts files, which can be divided into: variable declarations, module declarations, global type declarations, triple slash instructions, etc. according to their functions.
variable declaration
If we want to use the third-party library jQuery, a common way is to import jQuery through the <script> tag in html, and then we can use the global variable $ or jQuery. Suppose you want to get an element with id foo.
jQuery('#foo') // ERROR: Cannot find name 'jQuery'.
TS will report an error, because the compiler doesn't know what $ or jQuery is, so you need to declare this global variable to let TS know, and declare its type through declare var or declare let/const.
// 声明变量 jQuery
declare var jQuery: (selector: string) => any;
// let 和 var 没有区别,更建议使用 let
declare let jQuery: (selector: string) => any;
// const 声明的变量不允许被修改
declare const jQuery: (selector: string) => any;
declare function
// 声明函数
declare function greet(message: string): void;
// 使用
greet('hello')
declarative class
// 声明类
declare class Animal {
name: string;
constructor(name: string);
sayHi(): string;
}
// 使用
const piggy = new Animal('佩奇')
piggy.sayHi()
declaring object
// 声明对象
declare const jQuery: {
version: string
ajax: (url: string, settings?: any) => void
}
// 使用
console.log(jQuery.version)
jQuery.ajax('xxx')
You can also use the namespace namespace to declare objects. In the early days, the appearance of namespace was a keyword created to solve modularization. With the emergence of the ES6 module keyword, in order to avoid functional confusion, it is now recommended not to use it.
declare namespace jQuery {
const version: string
function ajax(url: string, settings?: any): void;
}
module declaration
Usually we introduce npm package, and its declaration file may come from two places:
- The type file built into the package, the type entry of package.json.
- Install the package type file corresponding to
@types/xxx
.
If the above two methods do not find the corresponding declaration file, then you need to manually write a declaration file for it, and declare the module through declare module.
Example: Manually fix type support for @alipay/h5data.
interface H5DataOption {
env: 'dev' | 'test' | 'pre' | 'prod';
autoCache: boolean;
}
declare module '@alipay/h5data' {
export function fetchData<T extends any>(
path: string,
option?: Partial<H5DataOption>,
): Promise<T>;
}
// 使用
import { fetchData } from '@alipay/h5data'
const res = await fetchData<{ data: 'xxx' }>('url/xxx')
Expansion module type
In some cases, the module already has a type declaration file, but some plug-ins are introduced, and the plug-ins do not support types. In this case, it is necessary to extend the type of the module. Or extend through declare module, because the types declared by the module will be merged.
declare module 'moment' {
export function foo(): string
}
// 使用
import moment from 'moment'
import 'moment-plugin'
moment.foo()
global type declaration
type scope
In Typescript, whenever a file has the import or export keyword, it is considered a module file. That is, regardless of the .ts file or the .d.ts file, if one of the above keywords exists, the scope of the type is the current file; if the above keywords do not exist, the types of variables, functions, enumerations, etc. in the file are all Exists in the project with global scope.
The global scope declares a global type
Types declared in the global scope are global types.
A local scope declares a global type
A global type can be declared in the local scope through declare global.
import type { MarketingType } from '@/constants'
declare global {
interface PageProps {
layoutProps: {
marketingType: MarketingType;
isAgencyRole: boolean;
};
}
}
triple slash command
The triple-slash command must be placed at the top of the file, and only single-line or multi-line comments are allowed before the triple-slash command.
The function of the triple slash instruction is to describe the dependencies between modules, which is usually not used, but it is still useful in the following scenarios.
- When writing a global type declaration file that depends on other types
- When you need to depend on a declaration file for a global variable
- When dealing with the problem of missing .d.ts files after compilation
When you need to write a global type declaration file that depends on other types
In the declaration file of global variables, import and export keywords are not allowed. Once they appear, the current declaration file is no longer the declaration file of the global type, so the triple slash instruction is required at this time. .
/// <reference types="jquery" />
declare function foo(options: JQuery.AjaxSettings): string;
A declaration file that depends on a global variable
When you need to depend on a declaration file of a global variable, since global variables do not support importing through import, you need to use the triple slash instruction to import.
/// <reference types="node" />
export function foo(p: NodeJS.Process): string;
When dealing with the problem of missing .d.ts files after compilation
When writing a project, the .d.ts file written in the project will not be placed in the corresponding dist directory after tsc is compiled. At this time, it is necessary to manually specify the dependent global type.
/// <reference path="types/global.d.ts" />
// ValueOf 来自 global.d.ts
export declare type ComplexOptions = ValueOf<typeof complexOptions>;
reference
- path: the path to the specified type file
- types: Specify the package corresponding to the type file, for example, the corresponding type file is
refer to
TypeScript Handbook:https://www.typescriptlang.org/docs/handbook/intro.html
TypeScript Learning: https://github.com/Barrior/typescript-learning
Advanced TypeScript Tricks You Don't Know: https://www.infoq.cn/article/7art3fu6ywdhxyqfss0w
TypeScript Getting Started Tutorial: https://ts.xcatliu.com/basics/declaration-files.html
Reading Type Gymnastics: An Introduction to TypeScript Type Metaprogramming Basics: https://zhuanlan.zhihu.com/p/384172236
JavaScript Metaprogramming: https://chinese.freecodecamp.org/news/what-is-metaprogramming-in-javascript/
other information
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。