Pressidian
花园入口
笔记
项目
关于
实验室
GitHub
花园入口
笔记
项目
关于
实验室
GitHub

KNOWLEDGE PATHS

笔记库
当前位置
笔记库/前端/工程化/类型检查/TS/类型体操

类型体操-实习

13 分钟阅读 · Note

目录树 578 篇

              • 类型体操-简单
              • 类型体操-实习
              • 类型体操-中等
              • cheat-sheets 小抄
              • TypeScript 中 `is` 与 `as` 的区别
            • TS项目经验
          • 运行时 Schema 校验
      • 前端技术栈
    • 笔记目录
    • CLAUDE.md
    • Vue 组件与 Render 函数

关联笔记 6

↗类型体操-简单同一路径↗类型体操-中等同一路径↗cheat-sheets 小抄同一路径↗TypeScript 中 `is` 与 `as` 的区别同一路径↗0Hello TypeScript共同主题↗编译选项共同主题
  • 类型体操-实习

类型体操-实习

在正式开始做类型体操前,请阅读这篇文章 https://juejin.cn/post/7616265766073221130

  • 条件类型 T extends U ? A : B —— 分支判断

  • 映射类型 { [K in keyof T]: ... } —— 遍历 + 变换

  • 递归 —— 处理嵌套结构

  • 模板字面量类型 —— 字符串拼接

  • infer —— 模式匹配提取

Remove Index Signature 删除索引签名

Implement RemoveIndexSignature<T> , exclude the index signature from object types.实现从对象类型中排除索引签名。 例如:

type Foo = {[key: string]: anyfoo(): void}type A = RemoveIndexSignature<Foo> 
// expected { foo(): void }

答案:

type RemoveIndexSignature<T> = {
  [K in keyof T as/* filters out all 'string' keys */
    string extends K
      ? never/* filters out all 'number' keys */
      : number extends K
        ? never/* filers out all 'symbol' keys */
        : symbol extends K
          ? never 
          : K /* all that's left are literal type keys */
   ]: T[K]
}

这个方案通过检查键是否是通用类型(string、number、symbol)的超类型来过滤掉索引签名

索引签名的设计目的是为了处理在编译时未知的属性访问。它的语义是"当使用任意字符串/数字/符号作为键访问对象时,返回值的类型是什么"。因此它必须使用通用类型,而非具体的字面量类型。#

Percentage Parser

实现类型 PercentageParser。根据规则 /^(\+|\-)?(\d*)?(\%)?$/ 匹配类型 T。

匹配的结果由三部分组成,分别是:[正负号, 数字, 单位],如果没有匹配,则默认是空字符串。

例如:

type PString1 = ''
type PString2 = '+85%'
type PString3 = '-85%'
type PString4 = '85%'
type PString5 = '85'

type R1 = PercentageParser<PString1> // expected ['', '', '']
type R2 = PercentageParser<PString2> // expected ["+", "85", "%"]
type R3 = PercentageParser<PString3> // expected ["-", "85", "%"]
type R4 = PercentageParser<PString4> // expected ["", "85", "%"]
type R5 = PercentageParser<PString5> // expected ["", "85", ""]

答案:

type PercentageParser<A extends string> = 
A extends `${infer O extends '+' | '-'}${infer N}%` 
    ? [O, N, '%']
    : A extends `${infer O extends '+' | '-'}${infer N}`
        ? [O, N, '']
        : A extends `${infer N}%`
            ? [N, '%']
            : A extends `${infer N}`
                ? ['', N, '']
                : never 

这里的答案并不能通过全部的测试,不过一般情况也不会去写这种类型吧

Drop Char

从字符串中剔除指定字符。

例如:

type Butterfly = DropChar<' b u t t e r f l y ! ', ' '> // 'butterfly!'

答案:

type DropChar<S, C extends string> = 
    S extends `${infer F}${C}${infer R}` 
        ? DropChar<`${F}${R}`, C> 
        : S 

使用递归的方式将字符剔除。最开始F为'', C为' ',R为' u t t e r f l y ! ',继承。将第一个' '剔除,剩余的'b u t t e r f l y ! '再次传入DropChar,此时F为'b', C为' ',R为'u t t e r f l y ! '。

PickByType

From T, pick a set of properties whose type is assignable to U.从T中将能够赋值给U的属性选出

For Example:

type OnlyBoolean = PickByType<{
  name: string
  count: number
  isReadonly: boolean
  isEnable: boolean
}, boolean> // { isReadonly: boolean; isEnable: boolean; }

Answer:

type PickByType<T, U> = {
  // 筛选符合要求的key,再映射
  [K in keyof T as T[K] extends U ? K : never] : T[K]
}

在 TypeScript 的映射类型(Mapped Types)中,as 关键字用于重命名或过滤键(Key)。

在你提供的代码片段中:

[K in keyof T as T[K] extends U ? K : never]

这里的 as 具体起到了过滤的作用。

  1. 基本语法: 在 TypeScript 4.1+ 版本引入的映射类型语法中,结构通常是:
{ [K in Keys] : Type } // 旧写法,只能遍历
{ [K in Keys as NewKey] : Type } // 新写法,可以重命名或排除
  1. as 后面的表达式决定了最终对象中保留哪些键,以及这些键叫什么名字。

  2. 在你的代码中的逻辑:

    • K in keyof T:遍历类型 T 的所有键。

    • as:对当前的键 K 进行转换判断。

    • T[K] extends U ? K : never:这是一个条件类型。

      • 如果 T 中键 K 对应的值类型 T[K] assignable to (可赋值给) U,则结果为 K(保留原键名)。

      • 否则,结果为 never。

  3. never** 的特殊行为**: 在映射类型的 as 子句中,如果计算结果是 never,TypeScript 会直接从结果类型中剔除该键。TS 的映射类型里,如果把某个 key 映射到 never,它就会被自动移除。

Mutable

实现一个通用的类型 Mutable&lt;T&gt;,使类型 T 的全部属性可变(非只读)。

例如:

interface Todo {
  readonly title: string
  readonly description: string
  readonly completed: boolean
}

// { title: string; description: string; completed: boolean; }
type MutableTodo = Mutable<Todo> 

答案:

type Mutable<T> = {
  - readonly [key in  keyof T]:T[key]
}

官方文档 https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#mapping-modifiers

OmitByType

From T, pick a set of properties whose type are not assignable to U.

For Example

type OmitBoolean = OmitByType<
    {name: stringcount: numberisReadonly: booleanisEnable: boolean}, 
boolean> // { name: string; count: number }

答案:

type OmitByType<T, U> = {
  [key in keyof T as T[key] extends U ? never : key] : T[key]
}

和PickByType很像

ObjectEntries

Implement the type version of Object.entries

For example

interface Model {
  name: string;
  age: number;
  locations: string[] | null;
}
type modelEntries = ObjectEntries<Model> 
// ['name', string] | ['age', number] | ['locations', string[] | null];

答案:

type ObjectEntries<T> = {
  [K in keyof T]-?: [K, T[K]]
}[keyof T]

这里去掉可选是因为 https://github.com/type-challenges/type-challenges/issues/14052

Reverse

和Array.reverse()一样

例子:

type a = Reverse<['a', 'b']> 
// ['b', 'a']

type b = Reverse<['a', 'b', 'c']> 
// ['c', 'b', 'a']

答案:

// 将第一个放到最后
type Reverse<T extends any[]> = 
    T extends [infer F, ...infer Rest] ? [...Reverse<Rest>, F] : T;

// 反之,将最后一个放到前面来  
type Reverse<T extends any[]> = 
    T extends [...infer rest, infer last] ? [last, ...Reverse<rest>] : T

思路是很典型的递归,每次将最后一个/第一个取出来,剩下的部分递归。

FlipArguments

答案:

type FlipArguments<T extends (...args: any[]) => any> = 
    T extends (...args: infer P) => infer U 
        ? (...args: Reverse<P>) => U
        : never;

使用Reverse&lt;&gt;将参数reverse一下。

BEM style string

The Block, Element, Modifier methodology (BEM) is a popular naming convention for classes in CSS.

For example, the block component would be represented as btn, element that depends upon the block would be represented as btn__price, modifier that changes the style of the block would be represented as btn--big or btn__price--warning.

Implement BEM&lt;B, E, M&gt; which generate string union from these three parameters. Where B is a string literal, E and M are string arrays (can be empty).

答案:

// 这里做了换行,是为了看的清楚,最好是写在一行里
type BEM<B extends string, E extends string[],M extends string[]> = 
  `
    ${B}
    ${E extends [] ? '' : `__${E[number]}`}
    ${M extends [] ? '' : `--${M[number]}`}
  `

数组转联合类型用 [number] 作为下标

Flip

将对象的键与值对调

Flip<{ a: "x", b: "y", c: "z" }>; // {x: 'a', y: 'b', z: 'c'}
Flip<{ a: 1, b: 2, c: 3 }>; // {1: 'a', 2: 'b', 3: 'c'}
Flip<{ a: false, b: true }>; // {false: 'a', true: 'b'}

答案:

// 这样写,会有几个测试过不了,需要优化
type Flip<T> = {
  [key in keyof T as T[key]] : key
}

// 优化后
type Flip<T extends Record<string, string | number | boolean>> = {
  [key in keyof T as `${T[key]}`] : key
}

使用Record是因为测试中的几个例子键只能是string、number与boolean,然后boolean是不做键的,需要转成字符串

https://github.com/type-challenges/type-challenges/issues/14094

Zip

将两个元组合并成一个

In This Challenge, You should implement a type Zip&lt;T, U&gt;, T and U must be Tuple

type exp = Zip<[1, 2], [true, false]> // expected to be [[1, true], [2, false]]

答案:

type Zip<T extends any[], U extends any[]> = 
    [T, U] extends [[infer L, ...infer restL], [infer R, ...infer restR]] 
        ? [[L, R], ...Zip<restL, restR>] 
        : []

这道题又是一个经典的递归,我最开始是使用映射类型来做的

IsTuple

判断一个类型是不是元组

type case1 = IsTuple<[number]> // true
type case2 = IsTuple<readonly [number]> // true
type case3 = IsTuple<number[]> // false

答案:

type IsTuple<T> = [T] extends [never] 
  ? false 
  : T extends readonly any[]
    ? number extends T['length'] ? false : true
    : false

这里解题的关键在于数组的length是nubmer类型的,而元组的length是一个特殊的固定的数

这里的[T] extends [never] 也是之前讨论的问题

TrimRight

将字符串右侧的空格去掉

type Trimmed = TrimRight<'   Hello World    '> 
// expected to be '   Hello World'

答案

// 注意最后有一个空格
type TrimRight<S extends string> = S extends `${infer Rest}${' '}` 
  ? TrimRight<Rest>
  : S

但是在测试的例子中存在\t这种转义字符,我们需要将他们过滤出来

type TrimRight<S extends string> = 
  S extends `${infer Rest}${ ' ' | '\t' | '\n' }` 
    ? TrimRight<Rest>
    : S

也是很经典的递归 + 字符串拼接 + 模式匹配提取

WithOut

数组去除指定的元素后再去重

type Res = Without<[1, 2], 1>; // expected to be [2]
type Res1 = Without<[1, 2, 4, 1, 5], [1, 2]>; // expected to be [4, 5]
type Res2 = Without<[2, 3, 2, 3, 2, 3, 2, 3], [2, 3]>; // expected to be []

答案:

type ToUnion<T> = T extends any[] ? T[number] : T
type Without<T, U> = 
  T extends [infer R, ...infer F]
    ? R extends ToUnion<U>
      ? Without<F, U>
      : [R, ...Without<F, U>]
    : T
    

Join

type Res = Join<["a", "p", "p", "l", "e"], "-">; // expected to be 'a-p-p-l-e'
type Res1 = Join<["Hello", "World"], " ">; // expected to be 'Hello World'
type Res2 = Join<["2", "2", "2"], 1>; // expected to be '21212'
type Res3 = Join<["o"], "u">; // expected to be 'o'

答案 https://github.com/type-challenges/type-challenges/issues/14125

type Join<T extends string[], U extends string | number> = 
  T extends [infer L extends string, ...infer R extends string[]]
    ? R['length'] extends 0
      ?  L
      : `${L}${U}${Join<R, U>}`
    : ''

Unique

Implement the type version of Lodash.uniq, Unique takes an Array T, returns the Array T without repeated values.

type Res = Unique<[1, 1, 2, 2, 3, 3]>; // expected to be [1, 2, 3]
type Res1 = Unique<[1, 2, 3, 4, 4, 5, 6, 7]>; // expected to be [1, 2, 3, 4, 5, 6, 7]
type Res2 = Unique<[1, "a", 2, "b", 2, "a"]>; // expected to be [1, "a", 2, "b"]
type Res3 = Unique<[string, number, 1, "a", 1, string, 2, "b", 2, number]>; // expected to be [string, number, 1, "a", 2, "b"]
type Res4 = Unique<[unknown, unknown, any, any, never, never]>; // expected to be [unknown, any, never]

答案 ,没看懂回学校看https://github.com/type-challenges/type-challenges/issues/14151

ConstructTuple

构造指定长度的元组

type result = ConstructTuple<2> // expect to be [unknown, unknown]

答案:

type ConstructTuple<L extends number, U extends unknown[] = []> = 
  U['length'] extends L
    ? U
    : ConstructTuple<L, [...U, unknown]>

这里使用了U extends unknown[] = [],这样在类型的定义的过程中就可以是使用U了,这个技巧经常出现

NumberRange

type NumberRange<
  L, 
  H, 
  Count extends any[] = [], 
  Res extends any[] = [] , 
  Flag extends boolean = Count['length'] extends L ? true : false
> = 
  Flag extends true
    ? Count['length'] extends H
      ? [...Res, Count['length']][number]
      : NumberRange<L, H, [...Count, ''], [...Res,  Count['length']], Flag>
    : NumberRange<L, H, [...Count, '']>

CheckRepeatedChars

type CheckRepeatedChars<T extends string> = T extends `${infer F}${infer E}` 
  ? E extends `${string}${F}${string}`
    ? true
    : CheckRepeatedChars<E>
  : false

ParseUrlParams

将Url转换为联合类型

ParseUrlParams<':id'> // id
ParseUrlParams<'posts/:id'> // id
ParseUrlParams<'posts/:id/:user'> // id | user

答案

type ParseUrlParams<T> = 
  T extends `${string}:${infer R}` 
    ? R extends `${infer F}/${string}` 
      ? F | ParseUrlParams<R> 
      : R
    : never

这道题我最开始也想到使用递归来做,但是没想到如何来构造成Union,我一开始是想要用数组存起来,之后在转为Union,这种做法反而麻烦了

GetMiddleElement

获取数组的中间元素

type simple1 = GetMiddleElement<[1, 2, 3, 4, 5]>, // expected to be [3]
  type simple2 = GetMiddleElement<[1, 2, 3, 4, 5, 6]> // expected to be [3, 4]

答案

type GetMiddleElement<T extends any[]> = 
  T extends [infer F, ...infer M, infer B ] 
    ? M extends [] 
      ? [F, B] 
      : GetMiddleElement<M> 
    : T

不断的将两侧的项剔除,直到剩最后的0个(数组偶数项)或1个项(数组奇数项),偶数直接返回其左右的项,奇数返回其本身

感觉基本上练的差不多了

协变与逆变

https://github.com/shfshanyue/Daily-Question/issues/713

TypeScript 逆变和协变 &gt; 文章中所有的代码均在这里 TS Playground - An online editor for exploring TypeScript and JavaScript ## 一些概念 ### Subtyping 子类型 在编程语言理论中,子类型是一种类型多态的形式,这种类型可以替换其超类型(supertype)。如果 S 是 T 的子类型,一般表示为 S &lt;: T,意思是在任何类型为 T 的地方都可以安全的使用 S。 js type T = number type S = T | string type Fn = (arg: T) =&gt; void let fn: Fn = (arg: S) =&gt; {} 上面的代码我们定义了 T 和 S 类型,然后定义了一个 参数为 T 返回值为 any 的函数类型 Fn, 但是在具体的赋值的时候我们将 args 变为 S,程序是可以正常运行的。 ### 协变与逆变 &gt; 协变与逆变(Covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。 在一门程序设计语言的类型系统中,一个类型规则或者类型构造器是: - 协变(covariant),如果它保持了子类型序关系≦。该序关系是:子类型≦基类型。 - 逆变(contravariant),如果它逆转了子类型序关系。 - 不变(invariant),如果上述两种均不适用。 维基百科中的描述有点抽象,我们提取一下关键词 - 父/子型别 - 构造器构造 - 是否具有父/子型别关系 我们先用简单的数学模拟一下,假设我们现在有两个值 x = -1 , y = -5,x 和 y 的关系为 x &gt; y。 假设现在有函数 $f(t) = t + 10$ 将我们的 x 和 y 代入 f(t) 后得到 f(x) = 9 和 f(y) = 5 ,可以发现 f(x) 和 f(y) 的关系仍然为 f(x) &gt; f(y),这就是典型的协变。 假设如下函数 $g(t) = t^2$ g(x) = 1 &lt; g(y) = 5 这就是逆变。 上面只是举了一个很简单并且不够严谨的例子,主要是为了方便理解,现在我们看一下在 ts 中的实现。 ### TypeScript实现 考虑如下类型 js dinterface Animal { name: string } interface Dog extends Animal { // 摇尾巴 waggingTail(): void } interface Corgi extends Dog { canWagging: false } 代码中我们定义了三个类型,他们之间的子集关系为 Corgi &lt;: Dog &lt;: Animal。 js // 协变 type AnimalList = Array&lt;Animal&gt; type DogList = Array&lt;Dog&gt; let animalArr: AnimalList = []; let dogArr: DogList = []; // ✅ DogList 为 AnimalList 子集 animalArr = dogArr; 我们再考虑一种特殊情况 js type Fn1 = (arg: Dog) =&gt; Dog Fn1 类型的子类型是什么呢? 是 (arg: Corgi) =&gt; Corgi吗? 考虑如下场景 !image 显然 Fn3 并不是 Fn1 的子类型。 我们分析一下这样为什么不被允许,由于 Fn3 类型的限制,这里 fn3 函数的参数只能传入 arg 为 Corgi 类型的函数,但是本来的 Fn1 是允许任何 Dog 类型的参数,所以 ts 禁止了这样的操作。 所以 Fn1 的子类型应该为参数可以接受任何类型的 Dog,返回值具有 Dog 所有值的类型。 所以正确答案是 (arg: Animal) =&gt; Corgi 。 !image &gt; 为了方便表示,这里我们定义 A → B 指的是以 A 为参数类型,以 B 为返回值类型的函数类型。 返回值类型很容易理解:柯基是狗的子类型。但参数类型则是相反的:动物是狗的父类。 用合适的术语来描述这个奇怪的表现,可以说我们允许一个函数类型中,返回值类型是协变的,而参数类型是逆变的。返回值类型是协变的,意思是 A &lt;: B 就意味着 (T → A) &lt;: (T → B) 。参数类型是逆变的,意思是 A &lt;: B 就意味着 (B → T) &lt;: (A → T) ( A 和 B 的位置颠倒过来了)。 一个有趣的现象:在 TypeScript 中, 参数类型是双向协变的 ,也就是说既是协变又是逆变的,而这并不安全。但是现在你可以在 TypeScript 2.6 版本中通过 --strictFunctionTypes 或 --strict 标记来修复这个问题。 参考 - 维基百科-子类型 - 维基百科-协变与逆变 - What are covariance and contravariance? | Stephan Boyer

一、核心问题:什么是协变和逆变?

Stephan Boyer 文章的核心思路是:当我们有一个简单的类型关系时(比如 Dog &lt;: Animal),把这些类型"塞进"更复杂的类型构造器(如数组、函数)之后,这个父子关系会怎么变化?

这就引出三种可能:

变化情况含义例子
协变 (Covariant)子类型关系保持方向不变Dog &lt;: Animal → Array&lt;Dog&gt; &lt;: Array&lt;Animal&gt;
逆变 (Contravariant)子类型关系反转方向Dog &lt;: Animal → (Animal → T) &lt;: (Dog → T)
不变 (Invariant)两个方向都不成立可变数组在某些语言中是不变的

二、为什么返回值是协变的?

这部分比较直觉。Boyer 的解释是:

> 如果我期望一个函数返回 Animal,而你给我的函数实际返回 Dog——这完全可以,因为 Dog 本身就是 Animal 的一种。所有对 Animal 的操作对 Dog 都是合法的。 > >

用你文章的例子:

type Fn1 = (arg: Dog) => Dog

// 返回值 Corgi <: Dog,所以协变成立
let fn: Fn1 = (arg: Dog): Corgi => ({ name: 'corgi', waggingTail() {}, canWagging: false })

返回值是"输出"位置——调用者拿到返回值后去使用它。返回一个更具体的子类型,调用者完全可以把它当作父类型用,毫无风险。


三、为什么参数是逆变的?(重点 & 难点)

这是最反直觉的部分。Boyer 用了一个非常精炼的推理:

思考方式:站在"调用者"的角度

假设你声明了一个变量 fn: (arg: Dog) =&gt; void,这意味着调用者有权传入任何 Dog(包括 Corgi、Husky 等等)。

现在问:什么样的函数可以安全地赋值给 fn?

**情况 1:赋值一个 **(arg: Corgi) =&gt; void

type Fn1 = (arg: Dog) => void
type Fn3 = (arg: Corgi) => void

let fn: Fn1 = fn3 // ❌ 不安全!

为什么不安全?因为:

  • 调用者认为 fn 接受任何 Dog

  • 调用者传入一个 Husky(Husky 也是 Dog)

  • 但 fn3 内部只处理 Corgi,它可能会访问 canWagging 属性

  • Husky 没有 canWagging → 运行时崩溃!

**情况 2:赋值一个 **(arg: Animal) =&gt; void

type Fn1 = (arg: Dog) => void
type Fn2 = (arg: Animal) => void

let fn: Fn1 = fn2 // ✅ 安全!

为什么安全?因为:

  • 调用者传入任何 Dog

  • fn2 能处理任何 Animal,而 Dog &lt;: Animal,所以 Dog 肯定能被 fn2 正确处理

  • 参数处理能力更"宽泛"的函数,反而是子类型

用一句话总结

> 参数是"输入"位置——能接受更多类型的函数更安全,所以函数参数类型是逆变的。 > >

这就是 Boyer 文章的核心洞见:

位置角色变型直觉
返回值输出(Producer)协变给你更具体的东西,你总能用
参数输入(Consumer)逆变能吃更多种类的食物,说明兼容性更强

四、组合规则

Boyer 进一步总结了函数类型的完整子类型规则:

如果 A &lt;: B 且 C &lt;: D,那么:

$(B \to C) <: (A \to D)$

翻译成人话:

  • 参数类型反过来(B 是 A 的父类型 → 逆变)

  • 返回值类型跟着走(C 是 D 的子类型 → 协变)

对应你文章中的结论:

Dog <: Animal,Corgi <: Dog

所以 (Animal → Corgi) <: (Dog → Dog)

完美验证。


五、TypeScript 的特殊处理

你文章最后提到的双向协变(Bivariance)是 TypeScript 的一个实用主义妥协:

  • 理论上,参数应该严格逆变

  • 实际中,TypeScript 2.6 之前默认参数是双向协变的(既允许子类型也允许父类型),因为这样在 DOM 事件回调等常见场景下更方便

  • --strictFunctionTypes 修复了这个问题,让函数参数回归严格的逆变检查

但要注意:方法(method shorthand)声明仍然是双向协变的,只有用箭头函数/函数表达式声明的才会严格逆变:

interface Example {
  method(arg: Dog): void       // 双向协变(宽松)
  arrow: (arg: Dog) => void    // 严格逆变(--strict下)
}

六、总结口诀

记住一个简单的模型:

> 函数是"吃参数、吐返回值"的机器。 > > - 吐出来的(返回值)→ 可以更具体 → 协变 > > - 吃进去的(参数)→ 要能吃更多 → 逆变 > >

希望这个解读让 Boyer 那篇文章的思路变得更清晰了。如果你对某个具体点还有疑问,随时可以继续讨论。