类型体操-实习
在正式开始做类型体操前,请阅读这篇文章 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 具体起到了过滤的作用。
- 基本语法: 在 TypeScript 4.1+ 版本引入的映射类型语法中,结构通常是:
{ [K in Keys] : Type } // 旧写法,只能遍历
{ [K in Keys as NewKey] : Type } // 新写法,可以重命名或排除
as后面的表达式决定了最终对象中保留哪些键,以及这些键叫什么名字。在你的代码中的逻辑:
K in keyof T:遍历类型T的所有键。as:对当前的键K进行转换判断。T[K] extends U ? K : never:这是一个条件类型。如果
T中键K对应的值类型T[K]assignable to (可赋值给)U,则结果为K(保留原键名)。否则,结果为
never。
never** 的特殊行为**: 在映射类型的as子句中,如果计算结果是never,TypeScript 会直接从结果类型中剔除该键。TS 的映射类型里,如果把某个 key 映射到never,它就会被自动移除。
Mutable
实现一个通用的类型 Mutable<T>,使类型 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<>将参数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<B, E, M> 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<T, U>, 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 逆变和协变 > 文章中所有的代码均在这里 TS Playground - An online editor for exploring TypeScript and JavaScript ## 一些概念 ### Subtyping 子类型 在编程语言理论中,子类型是一种类型多态的形式,这种类型可以替换其超类型(supertype)。如果 S 是 T 的子类型,一般表示为 S <: T,意思是在任何类型为 T 的地方都可以安全的使用 S。 js type T = number type S = T | string type Fn = (arg: T) => void let fn: Fn = (arg: S) => {} 上面的代码我们定义了 T 和 S 类型,然后定义了一个 参数为 T 返回值为 any 的函数类型 Fn, 但是在具体的赋值的时候我们将 args 变为 S,程序是可以正常运行的。 ### 协变与逆变 > 协变与逆变(Covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。 在一门程序设计语言的类型系统中,一个类型规则或者类型构造器是: - 协变(covariant),如果它保持了子类型序关系≦。该序关系是:子类型≦基类型。 - 逆变(contravariant),如果它逆转了子类型序关系。 - 不变(invariant),如果上述两种均不适用。 维基百科中的描述有点抽象,我们提取一下关键词 - 父/子型别 - 构造器构造 - 是否具有父/子型别关系 我们先用简单的数学模拟一下,假设我们现在有两个值 x = -1 , y = -5,x 和 y 的关系为 x > y。 假设现在有函数 $f(t) = t + 10$ 将我们的 x 和 y 代入 f(t) 后得到 f(x) = 9 和 f(y) = 5 ,可以发现 f(x) 和 f(y) 的关系仍然为 f(x) > f(y),这就是典型的协变。 假设如下函数 $g(t) = t^2$ g(x) = 1 < g(y) = 5 这就是逆变。 上面只是举了一个很简单并且不够严谨的例子,主要是为了方便理解,现在我们看一下在 ts 中的实现。 ### TypeScript实现 考虑如下类型 js dinterface Animal { name: string } interface Dog extends Animal { // 摇尾巴 waggingTail(): void } interface Corgi extends Dog { canWagging: false } 代码中我们定义了三个类型,他们之间的子集关系为 Corgi <: Dog <: Animal。 js // 协变 type AnimalList = Array<Animal> type DogList = Array<Dog> let animalArr: AnimalList = []; let dogArr: DogList = []; // ✅ DogList 为 AnimalList 子集 animalArr = dogArr; 我们再考虑一种特殊情况 js type Fn1 = (arg: Dog) => Dog Fn1 类型的子类型是什么呢? 是 (arg: Corgi) => Corgi吗? 考虑如下场景 !image 显然 Fn3 并不是 Fn1 的子类型。 我们分析一下这样为什么不被允许,由于 Fn3 类型的限制,这里 fn3 函数的参数只能传入 arg 为 Corgi 类型的函数,但是本来的 Fn1 是允许任何 Dog 类型的参数,所以 ts 禁止了这样的操作。 所以 Fn1 的子类型应该为参数可以接受任何类型的 Dog,返回值具有 Dog 所有值的类型。 所以正确答案是 (arg: Animal) => Corgi 。 !image > 为了方便表示,这里我们定义 A → B 指的是以 A 为参数类型,以 B 为返回值类型的函数类型。 返回值类型很容易理解:柯基是狗的子类型。但参数类型则是相反的:动物是狗的父类。 用合适的术语来描述这个奇怪的表现,可以说我们允许一个函数类型中,返回值类型是协变的,而参数类型是逆变的。返回值类型是协变的,意思是 A <: B 就意味着 (T → A) <: (T → B) 。参数类型是逆变的,意思是 A <: B 就意味着 (B → T) <: (A → T) ( A 和 B 的位置颠倒过来了)。 一个有趣的现象:在 TypeScript 中, 参数类型是双向协变的 ,也就是说既是协变又是逆变的,而这并不安全。但是现在你可以在 TypeScript 2.6 版本中通过 --strictFunctionTypes 或 --strict 标记来修复这个问题。 参考 - 维基百科-子类型 - 维基百科-协变与逆变 - What are covariance and contravariance? | Stephan Boyer
一、核心问题:什么是协变和逆变?
Stephan Boyer 文章的核心思路是:当我们有一个简单的类型关系时(比如 Dog <: Animal),把这些类型"塞进"更复杂的类型构造器(如数组、函数)之后,这个父子关系会怎么变化?
这就引出三种可能:
| 变化情况 | 含义 | 例子 |
|---|---|---|
| 协变 (Covariant) | 子类型关系保持方向不变 | Dog <: Animal → Array<Dog> <: Array<Animal> |
| 逆变 (Contravariant) | 子类型关系反转方向 | Dog <: Animal → (Animal → T) <: (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) => void,这意味着调用者有权传入任何 Dog(包括 Corgi、Husky 等等)。
现在问:什么样的函数可以安全地赋值给 fn?
**情况 1:赋值一个 **(arg: Corgi) => 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) => void
type Fn1 = (arg: Dog) => void
type Fn2 = (arg: Animal) => void
let fn: Fn1 = fn2 // ✅ 安全!
为什么安全?因为:
调用者传入任何
Dogfn2能处理任何Animal,而 Dog <: Animal,所以 Dog 肯定能被fn2正确处理参数处理能力更"宽泛"的函数,反而是子类型
用一句话总结
> 参数是"输入"位置——能接受更多类型的函数更安全,所以函数参数类型是逆变的。 > >
这就是 Boyer 文章的核心洞见:
| 位置 | 角色 | 变型 | 直觉 |
|---|---|---|---|
| 返回值 | 输出(Producer) | 协变 | 给你更具体的东西,你总能用 |
| 参数 | 输入(Consumer) | 逆变 | 能吃更多种类的食物,说明兼容性更强 |
四、组合规则
Boyer 进一步总结了函数类型的完整子类型规则:
如果 A <: B 且 C <: 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 那篇文章的思路变得更清晰了。如果你对某个具体点还有疑问,随时可以继续讨论。