microsoft / microsoft/TypeScript

Feature request: Dependent contextual inference

未关闭
#64,091 1 条评论 23 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

Suggestion
主要语言
Go
星标
111k
派生
14.3k
平均合并
2 天 4 小时
30 天内合并 PR
132

描述

### 🔍 Search Terms

Dependent contextual inference, self type checking types, can't infer type parameters from context-sensitive expressions, self referencing types

### ✅ Viability Checklist

- [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
- [x] This wouldn't change the runtime behavior of existing JavaScript code
- [x] This could be implemented without emitting different JS based on the types of the expressions
- [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- [x] This isn't a request to add a new utility type: https://github.com/microsoft/TypeScript/wiki/No-New-Utility-Types
- [x] This feature would agree with the rest of our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals

### ⭐ Suggestion

To keep it really terse the feature request (which I'm calling "dependent contextual inference") is when a contextual type of an expression is in the form `T extends F` perform `n` checks each with contextual type `T extends F` where `Tn-1` is the type of the expression after check `n-1` for `n > 1` and `T` for `n = 1` until the `Tn` is identitcal to `Tn-1` or `n >= 5`

Let's take an example...

```ts
declare const f:
unknown }>
(t: T) =>
ReturnType

const r = f({
a: "hello",
b: a => a.toUpperCase()
// ~
// 'a' is of type 'unknown'. (18046)
})
```

Today `a` is inferred as `unknown` that's because there's just two passes of check that happen, in the first pass the object literal is inferred as `{ a: string, b: anyFunctionType }` and because it's effectively a non-inferrable type as it contains an `anyFunctionType`, `T` has no inference candidates leading `T` to be inferred with it's constraint... and the second pass just happens with `T` being fixed to what was inferred in the first pass ie as if the user has written `f<{ a: unknown, b: (a: unknown) => unknown }>(...)`.

With dependent contextual inference the first pass remains the same and the object literal's type is inferred as `{ a: string, b: anyFunctionType }` but the second pass happens with the contextual type as `T extends { a: unknown: b: (a: { a: string, b: anyFunctionType }["a"]) => unknown }` and now object literal's type becomes `{ a: string, b: (a: string) => string }`, and checker does one more pass with contextual type `T extends { a: unknown, b: (a: { a: string, b: (a: string) => string }["a"]) }` resulting the object literal's type being the same as before and the checker stops as the last two checks produced identical types. And finally `T` is inferred as `{ a: string, b: (a: string) => string }`. And the checker does one final check with `T` being fixed to that as if the user had written `f<{ a: string, b: (a: string) => string }>(...)`.

I've implemented a shabby version in PR #64092 but maybe there's a better way to implement it.

### 📃 Motivating Example

Imagine you have a state machine like library written in javascript...

```js
const runMachine = (definition) => {
let { state, context } = definition.initial
while (true) {
if (state === undefined) break;
const next = definition.transitions[state]({ context })
state = next?.state
context = next?.context
}
return { state, context }
}

const result = runMachine({
initial: {
state: "loggedOut",
context: {}
},
transitions: {
loggedOut: ({ context }) => {
return { state: "loggingIn", context: { username: "devanshj", password: "1234" } }
},
loggingIn: ({ context }) => {
if (context.username === "devanshj" && context.password === "1234") {
return { state: "loggedIn", context: { ...context, accessToken: "whatever" } }
} else {
return { state: "loggedOut", context: { failedOnce: true } }
}
},
loggedIn: ({ context }) => {
console.log(context.accessToken)
}
}
})
```

And now you want to write types for it... Today the best typings look like this (with a little change in api ie the double invocation `runMachine()(...)`)...

```ts
declare const runMachine:
() =>
["context"] }) => void | TMachine
}
}>
(definition: TDefinition) =>
TMachine

const result = runMachine<
| { state: "loggedOut", context: {} | { failedOnce: boolean } }
| { state: "loggingIn", context: { username: string, password: string } }
| { state: "loggedIn", context: { username: string, password: string, accessToken: string, failedOnce?: boolean } }
>()({
initial: {
state: "loggedOut",
context: {}
},
transitions: {
loggedOut: ({ context }) => {
return { state: "loggingIn", context: { username: "devanshj", password: "1234" } }
},
loggingIn: ({ context }) => {
if (context.username === "devanshj" && context.password === "1234") {
return { state: "loggedIn", context: { ...context, accessToken: "whatever" } }
} else {
return { state: "loggedOut", context: { failedOnce: true } }
}
},
loggedIn: ({ context }) => {
console.log(context.accessToken)
}
}
})
```

This is the best because the expectation is that the final result is a discriminated union so that when you do `if (result.state === "loggedIn")` it narrows the result and the context in a way you can read `accessToken` from the context which is gauranteed by the logic. Another expectation is that each transition receives the `context` in the shape that is again gauranteed by the definition logic eg `loggingIn` must receive a context where `username` and `password` are defined because each `loggedOut` sends it.

So any other easy solution (eg the following) won't work...

```ts
declare const runMachine:

(definition: {
initial: { state: TState, context: TContext },
transitions:
Record<
TState,
(current: { context: TContext }) => void | { state: TState, context: TContext }
>
}) =>
{ state: TState, context: TContext }
```

Now in theory we can type it like this...

```ts
declare const runMachine:
}) =>
| void
| { state: keyof TDefinition["transitions"], context: object }
}
}>
(definition: TDefinition) =>
{ [S in keyof TDefinition["transitions"]]: { state: S, context: Context } }[keyof TDefinition["transitions"]]

// iterative over all transitions and collect the context for the given state
type Context =
| { [S in keyof TDefinition["transitions"]]:
ReturnType extends infer R
? R extends unknown
? R extends { state: TState, context: infer C }
? C
: never
: never
: never
}[keyof TDefinition["transitions"]]
| (TDefinition["initial"]["state"] extends TState
? TDefinition["initial"]["context"]
: never)

// just to satisfy the type checker
type Definition =
{ initial: { state: keyof any, context: object }, transitions: Record unknown> }
```

The result? It works exactly like the manually annotated one (ie each transition gets the context it would expect and the final result is a discriminated union) and the user has to write zero type annotations...

The only caveat is that it relies on dependent contextual inference and doesn't actually work today.

### 💻 Use Cases

Because any generic signature can be rewritten in form of `T extends F` and leverage dependent contextual inference, a lot of problems can be solved, here are few...

1. Inferring invariant generics

Let's take a simple example...

```ts
declare const update:
(f: (previous: T) => T) => T

update(previous => ({ count: typeof previous.count === "number" ? previous.count + 1 : 0 }))
```

Here `previous` in inferred as `unknown` so this doesn't compile. But it can be refactored to `T extends F`...

```ts
declare const update:
) => unknown>(f: T) => ReturnType

update(previous => ({ count: typeof previous.count === "number" ? previous.count + 1 : 0 }))
```

Here `previous` is inferred as `{ count: number }` and it compiles. It also gracefully handles infinite loops...

```ts
declare const update:
) => unknown>(f: T) => ReturnType

udpate(lol => ({ lol }))
// ~~~~~~~~~~~~~~~~~
// Dependent contextual inference requires too many passes and possibly infinite
```

However the user can use getters if they meant to a circular shape which would infer a recursive type...

```ts
declare const update:
) => unknown>(f: T) => ReturnType

const x = udpate(lol => ({ get lol() { return lol } }))
x.lol.lol.lol.lol.lol.lol // compiles
```

A popular user of invaraint generic would be [zustand](https://github.com/pmndrs/zustand)...

```ts
declare const create:
>["set"]
, get: Store>["get"]
) => unknown
>
(t: T) =>
Store>

interface Store
{ get: () => T
, set: (value: Partial) => void
}

const store = create((set, get) => ({
count: 0,
increment: () => set({ count: get().count + 1 })
}))
```

Today `store` is inferred as `Store` hence zustand requires it's users to explicitly type the generic with `create()(...)`, but with dependent contextual inference `store` can be inferred as `Store<{ count: number, increment: () => void }>`

2. Inferring m times n generics

Today we already do a good job of inferring a chain of n-generics...

```ts
declare const useQuery:

(query: { key: K, fetch: (key: K) => T, select: (data: T) => U }) => U

const result = useQuery({
key: "0",
fetch: key => +key,
select: data => [data]
})
```

But because we don't have existential types it's hard to support m times n generics... Except in theory we can rewrite them in `T extends F` form and it'd work with dependent contextual types...

```ts
declare const useQueries:
unknown, select: (data: ReturnType) => unknown } }>
(queries: T) =>
{ [I in keyof T]: ReturnType }}

const results = useQueries([
{
key: "0",
fetch: key => +key,
select: data => [data]
},
{
key: 1,
fetch: key => key.toString(),
select: data => ({ data })
}
])
```

A popular user of this m times n generics use case is [tanstack query](https://github.com/TanStack/query). Their `useQueries` looks like the `useQueries` above and is completely untyped.

3. Inferring self referencing generics

Today we again already do a good job of inferring `T extends F` types which are useful when typing any self referencing generics which occur in eDSLs example...

```ts
declare const createMachine:
>(definition: T) => "STUB"

type StateNode = {
initial?: keyof T["states" & keyof T],
states?: {
[K in keyof T["states" & keyof T]]: StateNode
}
}

createMachine({
initial: "a", // can only be "a" or "b"
states: {
a: {
initial: "a1", // can only be "a1" or "a2" or "a3"
states: {
a1: {},
a2: {},
a3: {}
}
},
b: {}
}
})
```

But you can't have functions in them as their parameters don't get inferred...

```ts
declare const createMachine:
& { context: object }>(definition: T) => "STUB"

type StateNode = {
initial?: keyof T["states" & keyof T],
states?: {
[K in keyof T["states" & keyof T]]: StateNode
},
entry?: (context: C) => void
}

createMachine({
initial: "a",
context: { hello: "world" },
states: {
a: {
initial: "a1",
states: {
a1: {},
a2: {},
a3: {}
},
entry: (context) => { // context is `object` instead of `{ hello: string }`
console.log("entered node a")
}
},
b: {}
}
})
```

This again is fixed by dependent contextual inference.

A popular user that will get benefitted by this is [xstate](https://github.com/statelyai/xstate). In fact xstate is the main motivation for this feature request, even in a langauge as powerful as typescript there is no smart type-safe state machine abstraction out there.

4. Many more use cases

Because `T extends F` are what I call "self-type-checking types" they are already very powerful and any inference problem can be refactored to it. So I think there are many many open issues that can be fixed by this... The only missing piece is that they don't work as expected when the expression is context-sensitive ie if it has functions in it... And hopefully we can fill that gap.

Thanks for reading!

PS: Linking some issues this will indirectly fix...
- #49618
- #51377
- #40439
- #51612
- #52047
- perhaps many more I'll update as I find them

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

首先审查依赖上下文推断提案以及 PR #64092 中现有的实现。将其行为与动机示例进行比较,包括有限遍历和无限循环的情况;当所请求的推断能够在不改变 JavaScript 输出的情况下工作,并处理所述的终止行为时,工作即告完成。

由索引模型根据 Issue 内容生成。

评估

技术栈
typescript
领域
compilers
Issue 类型
功能
难度
5/5
预计耗时
一周以上
活跃度
活跃
描述清晰度
描述清楚
新手友好度
25/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。