Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v5] Actor types #4036

Merged
merged 60 commits into from
Jul 27, 2023
Merged

[v5] Actor types #4036

merged 60 commits into from
Jul 27, 2023

Conversation

davidkpiano
Copy link
Member

@davidkpiano davidkpiano commented May 22, 2023

const machine = createMachine({
  types: {} as {
    actors: {
      src: 'fetchData'; // src name (inline behaviors ideally inferred)
      id: 'fetch1' | 'fetch2'; // possible ids
      input: { foo: string };
      output: { result: string };
    }
  },
  invoke: {
    src: 'fetchData', // strongly typed
    id: 'fetch2', // strongly typed
    onDone: {
      actions: ({ event }) => {
        event.output; // strongly typed as { result: string }
      }
    },
    input: { foo: 'hello' } // strongly typed
  }
});

@davidkpiano davidkpiano requested a review from Andarist May 22, 2023 01:57
@changeset-bot
Copy link

changeset-bot bot commented May 22, 2023

🦋 Changeset detected

Latest commit: 76b3fb7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
xstate Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codesandbox-ci
Copy link

codesandbox-ci bot commented May 22, 2023

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 76b3fb7:

Sandbox Source
XState Example Template Configuration
XState React Template Configuration

@ghost
Copy link

ghost commented May 22, 2023

👇 Click on the image for a new way to code review

Review these changes using an interactive CodeSee Map

Legend

CodeSee Map legend

@davidkpiano davidkpiano marked this pull request as ready for review May 23, 2023 05:53
// TODO: investigate why event is DoneInvoke<any> instead of
// DoneInvoke<{result: ...}>
// The type is properly inferred for:
// actions: ({ event }) => event.output.result
Copy link
Member

@Andarist Andarist Jun 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nested inference contexts strike again 😬

I think the conditional type that distributes over "non-fixed" (aka not yet set in stone) TActors is at fault here. I prepared a somewhat minimal repro of this problem: TS playground

Note that the type for that action object is pulled directly from the constraint, if we do this:

interface ActorImpl {
  src: string;
-  output: any;
+  output: unknown;
}

then we'll see DoneInvokeEvent<unknown> instead of DoneInvokeEvent<any>.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any workaround to this problem?

Copy link
Member

@Andarist Andarist Jun 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convince TS team to merge my microsoft/TypeScript#48838 😆 see how it works just alright in the playground that uses that PR: TS playground

On a serious note, I'd have to spend some time to figure this out for the current TS capabilities (if it's even possible)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one still falls back to the constraint - despite the fact that they have callable signatures already. I doubt that this is something that we can control rn without landing the mentioned fix and all I can do is to ping the TS team periodically to give it another look 🤷‍♂️

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That PR of mine mentions that the position of the conditional type can change the inferred result - so I guess I still have to try this out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried moving this conditional type in a variety of ways and couldn't make it work. I think it's best to assume, for now, that it's not possible to improve this and we can revisit this later.

@@ -1187,9 +1191,10 @@ describe('typegen types', () => {
}
},
{
// @ts-expect-error
// TODO: determine the exact behavior here and how eventsCausingActors + TActor should interact with each other
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: let's discuss this

@@ -947,7 +951,7 @@ describe('typegen types', () => {
fooActor: fromCallback((_send, onReceive) => {
onReceive((event) => {
((_accept: string) => {})(event.type);
// @x-ts-expect-error TODO: determine how to get parent event type here
// @ts-expect-error TODO: determine how to get parent event type here
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: I can try to do that in a follow up PR

// TODO: do not accept machines without all implementations
// we should also accept a raw machine as actor logic here
// or just make machine actor logic
export type Spawner = <T extends ActorLogic<any, any> | string>( // TODO: read string from machine logic keys
export type Spawner = <T extends AnyActorLogic | string>( // TODO: read string from machine logic keys
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: improve spawn

@datner
Copy link

datner commented Jul 27, 2023

@Andarist @davidkpiano
Hey, sorry to pop in unannounced like this. I saw that a lot of pain is coming from handling variance..
I don't imagine I can just drop in here and deliver the solution like I have all (or any) of the details, but maybe I'll bring a spark of inspiration

I don't know if you already know the following or not, I'm not assuming and I don't mean to offend, I'm just trying to help 😄

There are two ways that I know of of covariance and contravariance being properly expressed within typescript.

  1. the in and out variance modifiers on generics. Extremely niche but very much existing feature of typescript (not even documented on their website for some reason)
type Provider<out T> = () => T;
type Consumer<in T> = (x: T) => void;
type Mapper<in T, out U> = (x: T) => U;
type Processor<in out T> = (x: T) => T;
  1. "hack" typescript to respect variance
declare const Variance: unique symbol 
interface Provider<T> {
  [Variance]: {
    _T: (_: never) => T
  }
}

interface Consumer<T> {
  [Variance]: {
    _T: (_:T) => never
  }
}

interface Mapper<T,U> {
  [Variance]: {
    _T: (_:T) => never
    _U: (_: never) => U
  }
}

interface Processor<T> {
  [Variance]: {
    _T: (_:T) => T
  }
}

The latter being especially useful to keep track of types generically without widening types or smashing into bivariance bugs in more complex cases of working with types. Like lifting, reversing, extracting, and deriving.
there are other hacks in regards to tracking kind of typeinfo but I am not savvy enough to explain why they work, though I can try to connect you with those who can if it's useful.

Just to be clear, you're not constrained to functions, these concept extends to values just fine

export const Variance = Symbol()

interface Left<E> {
  readonly [Variance]: {
    readonly _A: (_: never) => never;
    readonly _E: (_: never) => E;
  };
}

interface Right<A> {
  readonly [Variance]: {
    readonly _A: (_: never) => A;
    readonly _E: (_: never) => never;
  };
}

interface Both<E,A> {
  readonly [Variance]: {
    readonly _A: (_: never) => A;
    readonly _E: (_: never) => E;
  };
}

type Either<E,A> = Left<E> | Right<A>
type These<E,A> = Either<E,A> | Both<E,A>

@Andarist
Copy link
Member

the in and out variance modifiers on generics. Extremely niche but very much existing feature of typescript (not even documented on their website for some reason)

TS team considers announcement blog posts to be part of the documentation 🤷‍♂️ So this is "documented" in TS 4.7 blog post.

"hack" typescript to respect variance

We already utilize a similar technique, for example here:

_out_TEvent?: TEvent;

Although this one is more about helping the inference engine to recognize this as a potential inference source than about actually enforcing variance.

Hey, sorry to pop in unannounced like this.

Don't be sorry! Every help is very much appreciated.

I saw that a lot of pain is coming from handling variance..

If this particular PR spurred this thought, then let me clarify what I'm doing in those tests. Type parameters have their own variance as long as they are actually used by structures. We rarely want to use runtime values as type containers (although at times that comes in handy and we might be using such tricks at some point. Those extensive~ tests were just added to ensure that the variance is correct. To some extent, we could just annotate type parameters with in/out and be done with it but:

  1. that requires TS 4.7 and we might still want to support older versions. I'm OK with breaking compat with older TS versions in general, but I don't feel that those annotations have good enough ROI to be an argument for that.
  2. it's easier for me to reason about how those variances play out in concrete examples than to reason about the implications of a local annotation. It's easy to reason about the annotated types but I have a hard time reasoning if they are doing what I want for structures references those annotated types. This just increases coverage and serves as an internal documentation, a more robust information for other maintainers about what we want to support etc

@datner
Copy link

datner commented Jul 27, 2023

We already utilize a similar technique, for example here:

As I said I'm not totally in the details, but I know there is some significant difference between

// this:
interface Foo<A> {
  _?: A
}

// and this:
interface Bar<A> {
  _?: (_: never) => A
}

The function signature notation is doing more than just signaling that it's covariant. I didn't mean to use properties as type containers, their shape is coincidental. I should have used only type lambas instead of indicating covariant/contravariant association. that was confusing of me. I've seen many phantom types around the repo. And I know you are well aware of functions changing how the engine determines type, as this quirk is utilized here:

export type Equals<A1 extends any, A2 extends any> = (<A>() => A extends A2

They can be both used together for greater good 😄

There is a way to avoid runtime representation using a symbol as the key for an optional field and not exporting it (basically expunging it from existence for everything besides types)

I do think it's worth some exploration where this are applicable within xstate. A much more mature and comprehensive example than I am capable of producing can be found here:
https://github.com/Effect-TS/io/blob/8fa68a9539c5fe379bee8835128181a9a6104f39/src/Effect.ts#L204

Theres example of extraction, unification (turning Foo<A, C, Z> | Foo<B, D, Z> to Foo<A | B, C | D, Z> in cases where this is the runtime behavior), blacklisting, and less relevantly so things like HKT and type lambdas.

xstate is insanely smart, and I really think it straddles the edge of typescript in some cases. I don't think it should eschew more esoteric usage if users get more free stuff, especially if theres a compat concern

@Andarist Andarist merged commit e2440f0 into next Jul 27, 2023
3 checks passed
@Andarist Andarist deleted the v5/machine-types-1 branch July 27, 2023 13:42
@Andarist
Copy link
Member

The function signature notation is doing more than just signaling that it's covariant.

I'm not aware of any difference. Not saying that it isn't there - TS sometimes acts differently based on details like this and the heuristics that it implements. So far though I didn't notice any drawbacks of the approach that we are using so I'm fine with sticking to it.

And I know you are well aware of functions changing how the engine determines type, as this quirk is utilized here:

Well, that one is just copy-pasted from SO 🤣 but overall I'm pretty familiar with how TS works under the hood (learning more and more every day! I certainly don't have a perfect knowledge about those things).

There is a way to avoid runtime representation using a symbol as the key for an optional field and not exporting it (basically expunging it from existence for everything besides types)

I'm slightly worried about using symbols because they are nominal and staying away from them allows us to potentially avoid some problems with mismatched package versions etc.

I do think it's worth some exploration where this are applicable within xstate.

Sure thing, I'm just not sure what does it solve for us today. If it solves some concrete problems, I would love to add tests showcasing those problems and using the alternative that would make them pass.

xstate is insanely smart, and I really think it straddles the edge of typescript in some cases. I don't think it should eschew more esoteric usage if users get more free stuff, especially if theres a compat concern

As I mentioned - I'm not strongly trying to avoid newer TS features. It's just that variance annotations aren't really strictly needed. You have to use those type parameters somewhere anyway because unused type parameters have unpredictable behaviors. And once type parameters are used they have their own variance already - annotations can't change them (well, they can make covariant/contravariant things invariant but that's not something that I'm looking for). In a way, those annotations should only be used as some kind of extra validation, like a lint rule.

@datner
Copy link

datner commented Jul 28, 2023

Thanks for the detailed responses @Andarist, I appreciate it greatly.

I don't have too much free time, but if you can point me to some tests you'd like me to try and take a crack at, I'll be totally open to give it a go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants