TypeScript
当前编写高阶类型时, 语法过于牵强, 比如用extends实现条件语句.
理应有像typetype那样的, 更符合常识的编写方法.
理应有像typetype那样的, 更符合常识的编写方法.
TypeScript当前只能允许特定类型, 而不能禁止特定类型.
https://github.com/microsoft/TypeScript/issues/10571
由于缺少该特性, 导致一旦手动输入一个泛型, 其他原本能够自动检测的泛型都会失去自动检测能力:
https://github.com/microsoft/TypeScript/issues/10571#issuecomment-940172215
https://github.com/microsoft/TypeScript/issues/10571#issuecomment-940172215
TypeScript无法正确处理泛型参数的衍生品的分支路径.
举例来说, 已知泛型
在将
举例来说, 已知泛型
Arr
是一个数组, Arr[Index]
是该数组的元素.在将
Arr[Index]
用于条件语句时, Arr[Index]
的类型推断实际上不能在后续流程中生效.解决此问题的一个小技巧是用
由于新的变量不是泛型参数的衍生品, 而是泛型本身, 它就会被TypeScript正确处理.
[T] extends [infer U]
来通过条件语句声明新的泛型.由于新的变量不是泛型参数的衍生品, 而是泛型本身, 它就会被TypeScript正确处理.
这些设计缺陷通常从一开始就存在, 相关讨论长达数年, 却看不到被解决的希望.
这很容易把人逼去写像Go和Rust那样的"接口+结构体+方法", 然而由于JavaScript的点运算符不会自动匹配符合签名的函数(自动装箱), 实际体验并不好.
这很容易把人逼去写像Go和Rust那样的"接口+结构体+方法", 然而由于JavaScript的点运算符不会自动匹配符合签名的函数(自动装箱), 实际体验并不好.
https://github.com/microsoft/TypeScript/issues/34516
https://github.com/microsoft/TypeScript/issues/33892
- https://github.com/microsoft/TypeScript/issues/27594
- https://github.com/microsoft/TypeScript/issues/38519
// 能检查到的错误abstract class MyClass { abstract value: number anotherValue = this.value // 报错}// 不能检查到的错误abstract class MyClass { abstract value: number anotherValue = this.getValue() // 不报错, 在运行时报错 getValue(): number { return this.value }}
- https://github.com/microsoft/TypeScript/issues/1373
- https://github.com/Microsoft/TypeScript/issues/3667
- https://github.com/microsoft/TypeScript/issues/10570
- https://github.com/microsoft/TypeScript/issues/23911
这导致子类需要重复编写类型定义, 进而导致所有不需要OOP封装的情况下, 类都不如组合使用接口和字面量对象.
这对抽象类构成的不便是最明显的:
子类在实现抽象类型时, TypeScript的类型推断很可能会产生错误的类型(例如将空数组推断为
子类在实现抽象类型时, TypeScript的类型推断很可能会产生错误的类型(例如将空数组推断为
never[]
), 进而导致需要主动声明类型.https://github.com/microsoft/TypeScript/issues/6413
当一个类被创建的目的是为了实现一个只包含可选属性的接口时, 使用该类的实例可能会遇到错误:
TS2559: Type 'X' has no properties in common with type 'Y'
.这是因为类没有将接口声明的全部属性包括在类的定义里.
有两种方法可以解决此错误:
有两种方法可以解决此错误:
- 将类的定义改成与接口相同(不推荐, 因为可能需要改动很多).
- 将实例的类型强制转换为接口的类型, 可以用工厂函数方便实现此功能.
参考: https://stackoverflow.com/questions/46449237/type-x-has-no-properties-in-common-with-type-y
https://github.com/microsoft/TypeScript/issues/3841
有时需要通过
this.constructor
来访问类的静态属性, 然而TypeScript将它的类型定义为Function, 这导致类型错误.class Sub extends Base { a = 1 // 2 constructor() { super() // 1 foo() // 4 } b = 2 // 3 (写在构造函数上面和下面没有区别, 执行顺序仍是晚于父类的构造函数, 早于当前类的构造函数)}
TypeScript 4.9新增的运算符, 旨在于一些场景替代as运算符.
用例见类型运算符对比.
as运算符执行的类型转换运算允许向下兼容:
只要左值能够赋值给右值类型, 转换就成立.
举例来说, 如果右值是一个接口, 则左值可以包含任何超出该接口的成员.
转换成立后, 返回右值类型.
只要左值能够赋值给右值类型, 转换就成立.
举例来说, 如果右值是一个接口, 则左值可以包含任何超出该接口的成员.
转换成立后, 返回右值类型.
需要注意, as运算符的这种直接返回右值类型的行为是 非常危险 的,
因为一个
而实际上我们想要当值是
因为一个
T | undefined
值可以被错误地强制转换为 T
.而实际上我们想要当值是
T
的时候返回 T
, 当值是 undefined
时返回 undefined
.satisfies运算符在类型转换运算时不允许向下兼容:
左值必须严格符合右值的接口, 否则转换不成立.
举例来说, 如果右值是一个接口, 则左值必须不包含任何超出该接口的成员.
转换成立后, 返回的是满足右值类型的左值类型, 这使类型转换变得更严谨, 从而避免出错.
左值必须严格符合右值的接口, 否则转换不成立.
举例来说, 如果右值是一个接口, 则左值必须不包含任何超出该接口的成员.
转换成立后, 返回的是满足右值类型的左值类型, 这使类型转换变得更严谨, 从而避免出错.
综上所述, 在很多场合下, satisfies运算符都可以替代as运算符.
类似于any和unknown的关系, satisfies应该成为首选的运算符, 仅在必要时使用as运算符.
类似于any和unknown的关系, satisfies应该成为首选的运算符, 仅在必要时使用as运算符.
type Colors = 'red' | 'green' | 'blue'type RGB = [red: number, green: number, blue: number]// 变量类型为// {// red: number[]// green: string// blue: number[]// }const palette = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255]}// 不符合 Record<Colors, string | RGB>// 变量类型为// {// readonly red: readonly [255, 0, 0]// readonly green: '#00ff00'// readonly blue: readonly [0, 0, 255]// }const palette = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255]} as const// 不符合 Record<Colors, string | RGB>, 但由于结果包含readonly修饰符, 不能再修改了// 变量类型为// Record<Colors, string | RGB> const paletteA: Record<Colors, string | RGB> = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255]}// 符合 Record<Colors, string | RGB>, 但red, green, blue的具体类型丢失// 变量类型为// Record<Colors, string | RGB> const palette = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255]} as Record<Colors, string | RGB>// 符合 Record<Colors, string | RGB>, 但red, green, blue的具体类型丢失// 变量类型为// {// red: [number, number, number]// green: string// blue: [number, number, number]// }const palette = { red: [255, 0, 0], green: '#00ff00', blue: [0, 0, 255]} satisfies Record<Colors, string | RGB>// 符合 Record<Colors, string | RGB>, 且red, green, blue的具体类型得到保留
enum Enum { A = 0}const val = 0const result = val in Enum // true
字符串枚举没有反向映射, 所以不能使用
in
运算符.enum Enum { A = 'a'}const val = 'a'const result = Object.values(Enum).includes(val) // true
enum Enum { A}const a = Enum.Aconst nameOfA = Enum[a] // 'A'
常量枚举在编译时会使用内联形式直接替换对应值, 因此与枚举相关的信息在编译后的代码里完全删除.
const enum Direction { Up, Down, Left, Right,} let directions = [ Direction.Up, Direction.Down, Direction.Left, Direction.Right,]// 编译后let directions = [ 0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */,]
- ts-toolbelt: https://github.com/millsp/ts-toolbelt
- purify: https://github.com/gigobyte/purify
- <<typetype>>: https://github.com/mistlog/typetype
- type-challenges: https://github.com/type-challenges/type-challenges
- fp-ts: https://github.com/gcanti/fp-ts
https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces
将type作为接口时和interface有一个重大区别: type创建的接口是不能扩展的.
type不是一个单独的类型, 而是其代表的类型的别名.
两个代表的类型相同的type是完全相等的, 无法以任何方式区别它们.
两个代表的类型相同的type是完全相等的, 无法以任何方式区别它们.
从TypeScript 4.1开始, 支持直接修改映射类型的键名.
type Getters<Type> = { [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]}type RemoveKindField<Type> = { [Property in keyof Type as Exclude<Property, 'kind'>]: Type[Property]}
Object
和 {}
是相同的, 接受的值相当于 any
.object
是在TypeScript 2.2中引入的, 指代指除primitive values以外的任何值.然而,
object
类型很难直接使用, 因为它没有property定义.在不开启strictNullChecks的情况下, 这三种类型都接受
为避免这三种类型接受
undefined
和 null
值.为避免这三种类型接受
undefined
或 null
, 一定要开启strictNullChecks选项.当用于函数的返回类型时,
以 =void= 为返回类型的函数签名可以兼容具有任何返回值的函数.
void
表示忽略函数的返回类型, undefined
则指定函数返回 undefined
类型.以 =void= 为返回类型的函数签名可以兼容具有任何返回值的函数.
type A = () => voidtype B = () => undefinedconst a: A = () => true // okconst b: B = () => true // oops!
unknown
被认为是类型安全版本的 any
.unknown
在静态检查时会报错, 而 any
会忽略一切错误.定义函数签名时, 可以额外设置prop, 只需要让它看起来像个接口.
// 类型形式type DescribableFunction = { (someArg: number): boolean description: string}// 接口形式interface DescribableFunction { (someArg: number): boolean description: string}
// 类型形式type SomeConstructor = { new (someArg: string): SomeObject}// 接口形式interface SomeConstructor { new (someArg: string): SomeObject}// 同时作为一般函数和构造函数interface CallOrConstruct { new (s: string): Date (n?: number): number}
将函数签名作为约束的关键在于如果args是任意的,
则类型需要是
则类型需要是
any
或 any[]
, 不能是 unknown
或 unknown[]
.T extends (...args: any) => any
接口继承自带约束效果, 继承的属性满足父接口的定义:
interface Base { prop: string | number}// OKinterface Sub1 extends Base { prop: string}// Type 'boolean' is not assignable to type 'string | number'interface Sub2 extends Base { prop: boolean}// Type 'boolean' is not assignable to type 'string | number'interface Sub3 extends Base { prop: string | boolean}// Type 'boolean' is not assignable to type 'string | number'interface Sub4 extends Base { prop: string | number | boolean}
&运算符只是一个类型运算操作:
interface Base { prop: string | number}// 找到并返回共有部分, 返回{ prop: string }type Result1 = Base & { prop: string }// 没有共有部分, 返回nevertype Result2 = Base & { prop: boolean }// 找到并返回共有部分, 返回{ prop: string }type Result3 = Base & { prop: string | boolean }// 找到并返回共有部分, 返回{ prop: string | number }type Result4 = Base & { prop: string | number | boolean }
在用&运算符模拟"继承"时, 可以利用其性质在一定程度上扩展父接口.
interface Interface1 { foo: { bar: number }}interface Interface2 { foo: { bar: number baz: string }}// Interface 'Result1' cannot simultaneously extend types 'Interface1' and 'Interface2'.// Named property 'foo' of types 'Interface1' and 'Interface2' are not identical.interface Result1 extends Interface1, Interface2 {}// 非重叠部分被合并, 返回// {// foo: {// bar: number// baz: string// }// }type Result2 = Interface1 & Interface2
interface Interface1 { foo(): { bar: number }}interface Interface2 { foo(): { bar: number baz: string }}// Interface 'Result1' cannot simultaneously extend types 'Interface1' and 'Interface2'.// Named property 'foo' of types 'Interface1' and 'Interface2' are not identical.interface Result1 extends Interface1, Interface2 {}// 非重叠部分被合并, 返回// {// foo(): {// bar: number// baz: string// }// }type Result2 = Interface1 & Interface2
很容易将
但如果两个接口的成员有重叠, 则会返回一个无法使用的接口(即带有never的接口), 这与混入有很大的差异.
&
误解为混入(mixin), 这是因为当两个接口的成员不重叠时, 其行为是符合开发人员预期的.但如果两个接口的成员有重叠, 则会返回一个无法使用的接口(即带有never的接口), 这与混入有很大的差异.
// 不同的类型type Foo = stringtype Bar = numbertype Result = Foo & Bar // never// 有重叠的union类型type Foo = string | booleantype Bar = number | booleantype Result = Foo & Bar // boolean// 有重叠的接口interface Foo { prop: string a: string}interface Bar { prop: number b: string}type Result = Foo & Bar // { a: string; b: string; prop: never }// 当接口里存在值类型为never的必要字段时, 该接口无法使用, 因此不应该在有重叠的接口上使用&.// 可选与必需成员重叠的接口interface Foo { prop?: string}interface Bar { prop: string}type Result = Foo & Bar // { prop: string }type Result = Bar & Foo // 该运算符合交换律, 即使调换顺序也会得到 { prop: string }// 不重叠的接口interface Foo { a: string}interface Bar { b: number}type Result = Foo & Bar // { a: string; b: number }
// 不同的类型type Foo = stringtype Bar = numbertype Result = Foo | Bar // string | number// 有重叠的union类型type Foo = string | booleantype Bar = number | booleantype Result = Foo | Bar // string | number | boolean// 有重叠的接口interface Foo { prop: string a: string}interface Bar { prop: number b: number}type Result = Foo | Bar// | { prop: string; a: string }// | { prop: number; b: number }// | { prop: string | number; a: string; b: number }// 可选与必需成员重叠的接口interface Foo { prop?: string}interface Bar { prop: string}type Result = Foo | Bar // { prop?: string | undefined }type Result = Bar | Foo // 该运算符合交换律, 即使调换顺序也会得到 { prop?: string | undefined }// 不重叠的接口interface Foo { a: string}interface Bar { b: number}type Result = Foo | Bar// | { a: string }// | { b: number }// | { a: string; b: number }
自TypeScript 4.3开始, 类的成员新增了override修饰符, 用来表明该成员覆盖了父类的同名成员.
当添加override修饰符时, TypeScript会检查父类是否存在同名成员.
TypeScript提供了一个flag, 可以强制要求那些覆盖父类成员的子类成员使用override修饰符.
当添加override修饰符时, TypeScript会检查父类是否存在同名成员.
TypeScript提供了一个flag, 可以强制要求那些覆盖父类成员的子类成员使用override修饰符.
Record的定义:
type Record<K extends keyof any, T> = { [P in K]: T}
当K的值是类型时, 它与 index signature 没有区别.
当K的值是字符串union时, 它与 index signature 不同, 如下:
type A = Record<'a' | 'b', string>// 等价于interface A { a: string b: string}
https://github.com/microsoft/TypeScript/issues/27024
export type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false
type Box<T> = { value: T }type ResultA = Equals<Box<string>, Box<any>> // falsetype ResultB = Equals<Box<string>, Box<unknown>> // falsetype ResultC = Equals<Box<any>, Box<string>> // falsetype ResultD = Equals<Box<unknown>, Box<string>> // false
type Box<T> = { value: T }type ResultA = Box<string> extends Box<any> ? true : false // truetype ResultB = Box<string> extends Box<unknown> ? true : false // truetype ResultC = Box<any> extends Box<string> ? true : false // truetype ResultD = Box<unknown> extends Box<string> ? true : false // falsetype ResultE = {} extends Box<string> ? true : false // falsetype ResultF = { value: any, other: unknown } extends Box<string> ? true : false // truetype ResultG = { value: unknown, other: unknown } extends Box<string> ? true : false // false
// 需要注意的是, 尽管extends最常见的形式是下面这种(形式上符合OOP的extends):T extends ...// 但extends的左值还可以是一个计算值, 所以下面的语法也是正确的:((...args: T) => any) extends ...
TypeScript可以直接读取对应索引的值, 甚至可以读取元组的length属性.
type Tuple = [string, number]type FirstOfTuple = Tuple[0] // stringtype Length = Tuple['length'] // 2
递归类型需要组合使用几种高级技巧,
其中最关键的部分是创建一个动态的对象类型, 将这个对象类型的索引当作分支条件使用.
其中最关键的部分是创建一个动态的对象类型, 将这个对象类型的索引当作分支条件使用.
type Head<T extends unknown[]> = T extends [infer U, ...unknown[]] ? U : nevertype Tail<T extends unknown[]> = T extends [unknown, ...infer U] ? U : []type HasTail<T extends unknown[]> = T extends [] | [unknown] ? false : true// 递归类型type Last<T extends unknown[]> = { 0: Head<T> 1: Last<Tail<T>>}[HasTail<T> extends true ? 1 : 0]
官方文档提供了常见模板: https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html
TypeScript里的
.ts
文件分为两种:- 模块: 包含顶级
import
/export
的.ts
文件 - 全局脚本: 不包含顶级
import
/export
的.ts
文件
全局脚本将自动被项目的其他脚本引用, 无需手动导入即可使用其中的类型.
模块中的类型需要手动导入.
模块中的类型需要手动导入.
因此,
文件后缀本身并不影响功能.
创建一个符合"全局脚本"定义的
.d.ts
文件仅用于为开发人员注明"这是一个不包含 import
/ export
的全局脚本",文件后缀本身并不影响功能.
创建一个符合"全局脚本"定义的
.ts
文件同样也能为项目添加全局类型和接口.- TypeSript对全局脚本的语法约束相当宽松, 这导致即使在写错全局脚本的情况下也能正常通过编译.
- 全局脚本和全局变量类似, 会导致难以追踪类型的来源.
对于大多数项目, 除非是为了添加模块定义, 否则不应该使用全局脚本.
这可以通过import函数实现, 不会破坏全局脚本的属性.
interface Test { value: import('other-module').Type}
declare
向环境添加一个已存在的标识符的类型声明.TypeScript项目自动生成的类型文件就是通过
declare
声明类型的, 作用类似于C语言的头文件.// 声明一个当前环境下已存在的函数签名declare function foo(): void// 声明一个当前环境下已存在的常量declare const BAR: string
以非侵入的形式为已经存在的模块创建类型声明.
declare module "my-module" { ...}
模块名还支持通配符, 以适用于Webpack等打包器.
declare namespace
适用于声明一个已存在的对象的类型.尽管声明对象的类型也可以用
declare const NAME: Interface
完成,但namespace胜在其内部是一个与外部无关的作用域,
因此可以在里面实施内部类型等有助于提升代码可读性的做法.
declare namespace
最常见的使用场景是用来声明以全局变量形式导入的JavaScript库.使用这种做法时, 只有通过
无法通过
window.foo
才能找到新添加的成员.无法通过
global.foo
, globalThis.foo
, 或直接以标识符访问全局变量的方式 foo
找到它.如果文件是一个模块, 声明合并无法扩散到模块之外, 需要使用
declare global
.declare global { interface Window { foo: string }}
如果文件是一个全局脚本, 就没有必要使用
declare global
, 直接就能声明合并.interface Window { foo: string}