打字稿状态机,可防止因编译错误而不允许的转换

问题描述 投票:0回答:1

我正在尝试创建一个状态机类型,其中的转换由编译器强制执行。

我想这样定义状态机:

type States = {
    state1: {state: 'state1'},
    state2: {state: 'state2'}
}

const Transitions: TransitionsType<States> = {
    init() {
        return {state: 'state1'}
    },
    state1: {
        state2({...anyOtherItems}) {
            // processing with anyOtherItems
            // maybe return a load of extra stuff here
            return {...anyOtherItems, state: 'state2'}
        }
    },
    state2: {
        state1({...others}) {
            // same deal here
            return {state: 'state1'}
        }
    }
};

transition(transitions, {state: 'state1'}, 'state2');

States 是一种描述机器状态的类型,以及该状态的数据类型。

Transitions 是一个对象,其 from 状态为映射的第一级,而 to 为映射的第二级。每次转换都会调用这些函数,它们将采用与 from 状态相关的类型,并返回与 to 状态相关的类型。

init
功能就是把这一切拉开序幕。

transition
是一个函数,它接受状态机定义、状态对象和 to 状态,并返回一个新的状态对象。

如果转换函数返回具有错误状态的对象,并且使用不允许的状态对象和

to
状态调用 transition 函数,我希望得到编译错误。

到目前为止我已经(并且可以完全忽略这一点):

type StatesType<S> = {
    [T in keyof S]: {state: T};
};

type TransitionTosType<S extends StatesType<S>, F extends keyof StatesType<S>> = {
    [T in keyof S]?: (arg: S[F]) => S[T]
}

type TransitionsType<S extends StatesType<S>> = {
    [F in keyof S]: TransitionTosType<S, F>
} & {
    init: () => S[keyof S]
}

function transition<S extends StatesType<S>>(transitions: TransitionsType<S>,
                                            state: S[keyof S],
                                            to: keyof S & string): {state: string} {
    const fromStateTransitions = transitions[state.state as keyof TransitionsType<S>];
    if (fromStateTransitions) {
        const toTransition = fromStateTransitions[to];
        if (toTransition)
            return toTransition(state);
    }
    throw new TypeError(`no transition from ${state.state} to ${String(to)}`);
}

这会给我带来一个错误

return toTransition(state);
,并且如果我尝试转换到不允许的状态,它不会给我一个错误。

我很乐意接受任何可以接受给定状态和转换的答案(示例中的

States
Transitions
),并提供一个接受一组转换(例如
Transitions
)的函数,一个状态正确类型的上下文(如
States
之类的类型中给出的),以及转换 to 的字符串,如果状态上下文是错误的类型,或者 to 状态是错误的,则会出现编译错误t 从 from 状态的有效转换。

我对接受类型参数的转换函数感到满意。

我也会接受解释为什么不可能作为答案。

typescript state-machine typing
1个回答
0
投票

我在代码示例中看到的主要问题是,手动指定

transitions
的类型为
TransitionsType<States>
与允许编译器从初始化 对象文字 推断出更具体的内容之间存在冲突。类型
TransitionsType<States>
具有相当明确的
States
类型表示,您需要它才能使
transition(transitions, ⋯)
正确运行。但是,如果您使用该类型,那么编译器不知道状态图的细节以及哪些节点连接到哪个节点。您确实需要使用更窄的类型,如下所示:

const transitions = {
  init() {
    return { state: 'state1' }
  },
  state1: {
    state2({ ...anyOtherItems }) {
      return { ...anyOtherItems, state: 'state2' }
    }
  },
  state2: {
    state1({ ...others }) {
      return { state: 'state1' }
    }
  }
} satisfies TransitionsType<States>;

我使用

satisfies
运算符来验证该类型的可分配性,并提供一个 context 来推断
transitions
的类型。推断的类型是:

const transitions: {
    init(): {
        state: "state1";
    };
    state1: {
        state2({ ...anyOtherItems }: {
            state: 'state1';
        }): {
            state: "state2";
        };
    };
    state2: {
        state1({ ...others }: {
            state: 'state2';
        }): {
            state: "state1";
        };
    };
}

但是

transitions
的类型并不明确包含
States
类型。因此,您需要
transition(transitions, ⋯)
才能梳理
transitions
的类型以恢复原始
States
类型,该类型不一定完全存在(想象一个其中一个状态已断开连接的图表)。

可能有一些巧妙的方法可以两全其美,但在我的尝试中,输入

transition()
变得如此复杂,以至于对我来说似乎不值得。


相反,我的方法是接受您需要显式地将两条信息传递给

transition()
:类型级别的
States
类型,以及
transitions
的具体形状。理想情况下,调用看起来像这样

transition<States>(transitions, state, to);

但不幸的是,类型级别的类型传递需要所谓的“部分类型参数推断”,如 microsoft/TypeScript#26272 中所要求的,目前 TypeScript 不支持该功能。例如,请参阅Typescript:在可选的第一个泛型之后推断泛型的类型。解决方法涉及一些重构。最常见的是类型级 currying,因此调用

transition<States>(transitions, state, to)
会返回一个使用
transition<States>()
作为参数调用的函数,而不是
(transitions, state, to)

它可能看起来像这样:

function transition<S extends StatesType<S>>() {

  function f<T extends TransitionsType<S>, F extends S[keyof S]>(
    transitions: T,
    state: F,
    to: keyof T[F["state"] & keyof T]
  ): void;

  function f(transitions: any,
    state: any,
    to: any): { state: string } {
    const fromStateTransitions = transitions[state.state];
    if (fromStateTransitions) {
      const toTransition = fromStateTransitions[to];
      if (toTransition)
        return toTransition(state);
    }
    throw new TypeError(`no transition from ${state.state} to ${String(to)}`);
  }
  return f;
}

所以现在当你调用

transition<States>()
时,函数内部
S
的类型就已经确定了,并且可以用来 constrain
T
(对应于
transitions
的泛型类型,以及
F
(对应于
state
的泛型类型),因此
to
的类型是
F
T
的易于编写的函数。

也就是说,如果

T
transitions
的类型,并且
F
state
的类型,那么
to
一定是
keyof T[F["state"]]
类型(
transitions 属性的键之一) 
对应状态
F
)。请注意,我必须编写
T[F["state"] & keyof T]
才能让编译器相信
F["state"]
可以作为
T
的键。并没有“直接”写出这是真的,至少对于泛型来说不是这样,并且编译器无法推断出它。 交集让编译器无需担心它。

好吧,让我们尝试一下:

const transitionFunc = transition<States>(); transitionFunc(transitions, { state: 'state1' }, 'state2'); // okay transitionFunc(transitions, { state: 'state1' }, 'state1'); // error transitionFunc(transitions, { state: 'state2' }, 'state2'); // error transitionFunc(transitions, { state: 'state2' }, 'state1'); // okay transitionFunc(transitions, { state: 'state3' }, 'state2'); // error

看起来不错。编译器只允许您传入适当类型的参数,并且如果无法从 
to

直接访问您的

state
状态,则会向您发出警告。

Playground 代码链接

© www.soinside.com 2019 - 2024. All rights reserved.