Propagation of generic types throughout multiple systems?

Hello Devforum! Today I was working on something I’ve admittedly never played around with too much, that being generic types. I implemented generics in a State Manager implementation for my personal framework, these being responsible for providing a template of data for the per-state and global shared data, which would theoretically let you have typechecking in the domain for developing Entity AI’s

Extra Information (About project & framework)

I’ve decided to go more in-depth about the implementation side to answer any possible questions, but just wanted to hide it.

This implementation (a module in my framework) provides a simple DSL-style experience to build Finite-State Machines, and there’s this value called the “environment” which allows different states and external script store & fetch data about the specific machine they represent. However, not having a dedicated way to provide typechecking makes it a little annoying to use, and I just love trying new things and so this is a great way to practice the use of generics! Now I’m just having a difficult time getting these generics to move between my objects that I create, in this post I specifically am going over my use case, an Entities object for the game I’m working on, however I’m trying to use as broad language as possible, however the stem from game → framework is still important.

Game → Framework (Seemingly fine)

Once implemented, I noticed the generics don’t fully propagate. The top layer (game logic) provides the types, but anything the framework creates loses them.


entity.Entity<{test: string}> = entity.new(script.Name, {}) → This generates a new entity with the table as TStEnv (Type State Environment)

From there, Entity generates a FSM and injects “Cortex” as TShEnv (Type Shared Environment)
The types.lua file then defines FSM as StateMachine<FSM_Cortex, TStEnv>, which is exactly what we see.

Access to Framework Data Issues

Environment (Framework Cache)
As you see in the screenshot, when you access environment through self.fsm (only accesses the shared environment), it provides the generic type of TShEnv!! (likely due to this line, not sure if I’m doing something wrong with it though, just isn’t propagating?)

When Entity creates a FSM it takes the TShEnv & TStEnv and binds them to the StateMachine instances that require them.

Entity.new<TStEnv>() Entrypoint

FSM Creation + Cortex (senses) injection


:hook()'ing a state lets you listen to different events in the StateMachine, and provides another type of environment for the individual state! This is why I also added TStEnv, so you can template the data that may be persistent per-state, but this may be omitted for simplicity.

The important part of this though is how env.shared doesn’t provide the type we want, and just the generic TShEnv.

It’s 2:41 AM I gotta go to bed I really hope this all makes sense, I really do hope you guys are interested in this kind of stuff and we can have some good discussions and hopefully we can all make something really cool with this, do let me know if you need more info and thank you for reading, have a great rest of your day!

  1. What lsp are you using
  2. Have you checked that the generic types “not being propagated” is actually only a visual issue, not a practical one? I’d like to see you index into fsm.environment and see if the declared fields appear
  1. Nighttrain’s (did completely forget it could be a lsp issue)
  2. Do you mean if the values truly exist? If so test the system is fully functional, im just trying to build typechecking to aid developer experience + integrate DSLs into environment pipelines, these generic types are just my attempt at doing that

Yea, see if the values you passed for the generics pop up in the autocomplete

Use luau lsp, roblox lsp is widely considered to be old and deprecated

Neither values that I added to the types pop up unfortunately, neither the cortex DSL I pass for TShEnv or the test: string for TStEnv

And will try to make that switch it’s just so much easier to write type files with this one :sob:, will have to be tomorrow though

Just updated my LSP to LuaU and I am finding considerably more luck, and the generics seem to work on the surface-access level just fine, and provides my DSL:

But this time when you’re in the :hook() environment, it shows up as this weird type:

And when I try to actually access any value inside of the environment, it returns no type results, as well as showing type of shared just being provided as " 'a ". Accessing through env.shared should return the same as if you accessed outside with fsm.environment, so I should see “senses” here.

Entity creates FSM with a SharedEnv type (Left), and the TypeDef file handles it (Right:

With that in mind, it should be noted that you can access a state’s environment through SawdustState.environment, and as you can see in this photo we can also properly access what should be passed in :hook() directly, just like we did to the shared self.fsm.environment, and again it provides the full DSL!

So somewhere in either state.lua or types.lua (section for state.lua) the type for the environment isn’t being properly provided to :hook(function(env))

Are function arguments a limitation? Or is there something I’m still doing wrong? Appreciate all of your help so far and I really hope this isn’t a yap fest and makes sense :sleeping_face:

1 Like

The `a makes me think this is a type-solver issue where a placeholder type used during solving is leaking into the result and messing things up. Your code might simply be too complex to infer properly.

It looks like you’re annotating your internal classes with lots of cyclic metatable magic and relying on inference to figure it out. Perhaps instead you could just force-cast a fake table type onto the class, so the end user still gets good generic support?

That’s what I’m trying to do with the types.lua file, which provides all of the types w/ generics used by constructing pseudo-tables with empty functions for definition, I may not be fully understanding what you mean though.

Also how do you mean cyclic metatable magic? I did get warnings about TypeError: Recursive type being used with different parameters. if that’s a part of it? Apologies, this is my first time implementing generics though so I may have gotten a tad bit ahead of myself :pensive_face: but I’m holding hope this is possible

What I meant was instead of trying to capture the inferred type with typeof(setmetatable(...)), you could just return a faux table type, like this:

local StateTransition = {}
StateTransition.__index = {}

...

export type StateTransition = {
	conditions: {TransitionConditionData},
	
	runTransition: (self: StateTransition) -> (),
	runConditionals: (self: StateTransition) -> (),
	eventCalled: (self: StateTransition, event_id: string) -> (),
}

return (StateTransition :: any) :: {new: () -> StateTransition}

The reason the idiomatic OOP approach is so hard to infer is because it is cyclic (__index points to itself). The solver will try to ‘flatten’ these recursive types which results in massive types with bizarre subtyping behavior.

This is why you’re better off just faking the types with a simple non-cyclic table. This way you won’t run into weird subtyping bugs.

1 Like