TypeScript

TypeScript没有对枚举执行严格的类型检查:
整数字面量在TypeScript里可以跨枚举使用.
官方称当前行为是为了支持位运算.
https://github.com/Microsoft/TypeScript/issues/26362
当前编写高阶类型时, 语法过于牵强, 比如用extends实现条件语句.
理应有像typetype那样的, 更符合常识的编写方法.
TypeScript当前只能允许特定类型, 而不能禁止特定类型.
https://github.com/microsoft/TypeScript/issues/10571
由于缺少该特性, 导致一旦手动输入一个泛型, 其他原本能够自动检测的泛型都会失去自动检测能力:
https://github.com/microsoft/TypeScript/issues/10571#issuecomment-940172215
TypeScript无法正确处理泛型参数的衍生品的分支路径.
举例来说, 已知泛型 Arr 是一个数组, Arr[Index] 是该数组的元素.
在将 Arr[Index] 用于条件语句时, Arr[Index] 的类型推断实际上不能在后续流程中生效.
解决此问题的一个小技巧是用 [T] extends [infer U] 来通过条件语句声明新的泛型.
由于新的变量不是泛型参数的衍生品, 而是泛型本身, 它就会被TypeScript正确处理.
这些设计缺陷通常从一开始就存在, 相关讨论长达数年, 却看不到被解决的希望.
这很容易把人逼去写像Go和Rust那样的"接口+结构体+方法", 然而由于JavaScript的点运算符不会自动匹配符合签名的函数(自动装箱), 实际体验并不好.
有相同结构的不同类在TypeScript中可以互相替代, 通过特定类的类型检查.
这个设计的糟糕之处在于这本应该通过接口来完成,
现有方案破坏了很多有意义的用例, 使得类型系统失去意义.
const symbol = Symbol()
class MyClass {
private [symbol] = true
}
enum Type {
MyClass
}
class MyClass {
private type = Type.MyClass as const
}
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的类型推断很可能会产生错误的类型(例如将空数组推断为 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运算符.
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 = 0
const 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.A
const 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是完全相等的, 无法以任何方式区别它们.
从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的情况下, 这三种类型都接受 undefinednull 值.
为避免这三种类型接受 undefinednull, 一定要开启strictNullChecks选项.
当用于函数的返回类型时,
void 表示忽略函数的返回类型, undefined 则指定函数返回 undefined 类型.
以 =void= 为返回类型的函数签名可以兼容具有任何返回值的函数.
type A = () => void
type B = () => undefined
const a: A = () => true // ok
const 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是任意的,
则类型需要是 anyany[], 不能是 unknownunknown[].
T extends (...args: any) => any
接口继承自带约束效果, 继承的属性满足父接口的定义:
interface Base {
prop: string | number
}
// OK
interface 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 }
// 没有共有部分, 返回never
type 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
很容易将 & 误解为混入(mixin), 这是因为当两个接口的成员不重叠时, 其行为是符合开发人员预期的.
但如果两个接口的成员有重叠, 则会返回一个无法使用的接口(即带有never的接口), 这与混入有很大的差异.
// 不同的类型
type Foo = string
type Bar = number
type Result = Foo & Bar // never
// 有重叠的union类型
type Foo = string | boolean
type Bar = number | boolean
type 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 = string
type Bar = number
type Result = Foo | Bar // string | number
// 有重叠的union类型
type Foo = string | boolean
type Bar = number | boolean
type 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修饰符.
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>> // false
type ResultB = Equals<Box<string>, Box<unknown>> // false
type ResultC = Equals<Box<any>, Box<string>> // false
type ResultD = Equals<Box<unknown>, Box<string>> // false
type Box<T> = { value: T }
type ResultA = Box<string> extends Box<any> ? true : false // true
type ResultB = Box<string> extends Box<unknown> ? true : false // true
type ResultC = Box<any> extends Box<string> ? true : false // true
type ResultD = Box<unknown> extends Box<string> ? true : false // false
type ResultE = {} extends Box<string> ? true : false // false
type ResultF = { value: any, other: unknown } extends Box<string> ? true : false // true
type 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] // string
type Length = Tuple['length'] // 2
递归类型需要组合使用几种高级技巧,
其中最关键的部分是创建一个动态的对象类型, 将这个对象类型的索引当作分支条件使用.
type Head<T extends unknown[]> =
T extends [infer U, ...unknown[]]
? U
: never
type 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库.
可以通过TypeScript的声明合并特性实现, Window 接口的成员会被并入已存在的 Window 接口.
使用这种做法时, 只有通过 window.foo 才能找到新添加的成员.
无法通过 global.foo, globalThis.foo, 或直接以标识符访问全局变量的方式 foo 找到它.
如果文件是一个模块, 声明合并无法扩散到模块之外, 需要使用 declare global.
declare global {
interface Window {
foo: string
}
}
如果文件是一个全局脚本, 就没有必要使用 declare global, 直接就能声明合并.
interface Window {
foo: string
}