Skip to content

Fix component type inference - #30

Merged
centau merged 1 commit into
centau:mainfrom
HarryXChen3:component-type
Sep 3, 2026
Merged

centau merged 1 commit into
centau:mainfrom
HarryXChen3:component-type

Conversation

@HarryXChen3

@HarryXChen3 HarryXChen3 commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

See this comment for details

Fixes component type inference on:
registry:set
registry:insert
registry:patch
registry:find
registry:copy

Currently, the behavior is:

local Name = ecr.component() :: string
local registry = ecr.registry()

local e = registry:create()
registry:set(e, Name, "haha") -- OK
registry:set(e, Name, 9999) -- Also OK ..

With the changes, the type for registry:set (and others) behave as expected:

local Name = ecr.component<<string>>()
local registry = ecr.registry()

local e = registry:create()
registry:set(e, Name, "haha") -- OK
registry:set(e, Name, 9999) -- TypeError: Expected this to be 'string', but got 'number'

Breaking Changes

  • Breaks backwards compatibility with old solver (if there was previously)
  • Views (registry:view) with more than 8 components do not get types past the 8th component
  • Observers (registry:track) with more than 8 components do not get types past the 8th component
  • Groups (registry:group) with more than 5 components do not get types past the 5th component
  • ecr.component usage (typechecking only)

Example Usage

Old

ecr.component() :: string
ecr.component() :: number
ecr.component(function() return "hello!" end)

New

ecr.component<<string>>()
ecr.component<<number>>()
ecr.component(function() return "hello!" end) -- no change, inferred as ecr.Component<string>

Notes

  • It may look like there are unrelated changes, but they were required to get the type solver to behave correctly for the new types
  • Tested on luau-lsp v1.69.0

Comment thread src/ecr.luau Outdated
@centau

centau commented Aug 18, 2026

Copy link
Copy Markdown
Owner

I think we should go with component<T> instead of Component<T>

@centau

centau commented Aug 18, 2026

Copy link
Copy Markdown
Owner

CHANGELOG.md should also note this change

@centau

centau commented Aug 18, 2026

Copy link
Copy Markdown
Owner

This is another issue that needs to be solved but I haven't been able to satisfy it yet

type component<T> = { read __ctype: T } -- consider read vs read+write
local number: component<number> = nil :: any
local string: component<string> = nil :: any

local function accept_any(...: component<unknown>)
end

local function set<T>(ctype: component<T>, value: T)
end

accept_any(number, string) -- SHOULD PASS
set(number, 1) -- SHOULD PASS
set(number, "") -- SHOULD ERROR, BUT DOES NOT

@centau

centau commented Aug 18, 2026

Copy link
Copy Markdown
Owner

This satisfies the below cases, but does the conditional read/write of the __ctype field cause undesired behavior in cases not listed here?

--type component<T> = { read __ctype: T } -- consider read vs read+write

type function component(T: type): type
    local key = types.singleton("__ctype")
    local t = types.newtable()

    if T.tag == "unknown" then
        t:setreadproperty(key, T)
    else
        t:setproperty(key, T)
    end

    return t
end

local number: component<number> = nil :: any
local string: component<string> = nil :: any

local function accept_any(...: component<unknown>)
end

local function set<T>(ctype: component<T>, value: T)
end

accept_any(number, string) -- SHOULD PASS
set(number, 1) -- SHOULD PASS
set(number, "") -- SHOULD ERROR

@centau

centau commented Aug 18, 2026 •

Copy link
Copy Markdown
Owner

I did some experimenting and this is what I settled on

--------------------------------------------------------------------------------
-- component type candidates
--------------------------------------------------------------------------------

--type component<T> = { read __ctype: T }

export type component<T> = { __ctype: T }

-- type component<T> = T

-- type function component(T: type): type
--     local key = types.singleton("__ctype")
--     local t = types.newtable()

--     if T.tag == "unknown" then
--         t:setreadproperty(key, T)
--     else
--         t:setproperty(key, T)
--     end

--     return t
-- end

export type unknown_component = { read __ctype: unknown }

--------------------------------------------------------------------------------
-- test functions
--------------------------------------------------------------------------------

-- Registry:clear()
local function clear(...: unknown_component)
end

-- Registry:set()
local function set<T>(ctype: component<T>, value: T)
end

-- Registry:get()
local function get<T>(ctype: component<T>): T
    return nil :: any
end

-- hypothetical function
local function serdes(ctype: component<string>|component<number>): buffer
    return nil :: any
end

--------------------------------------------------------------------------------
-- component definitions
--------------------------------------------------------------------------------

local function component<T>(): component<T>

local c = {
    number = component<<number>>(),
    string = component<<string>>(),
    boolean = component<<boolean>>(),
    unknown = component<<unknown>>(),
    number_or_string = component<<number|string>>(),
}

--------------------------------------------------------------------------------
-- test cases
--------------------------------------------------------------------------------

-- SHOULD PASS
clear(c.number, c.string)
set(c.number, 1)
set(c.unknown, 1)
set(c.number_or_string, 1)
serdes(c.number)

-- SHOULD ERROR
set(c.number, "")
set(c.string, 1)

All cases pass, we just need to also expose a special case for unknown components type unknown_component = { read __ctype: unknown }

@HarryXChen3
HarryXChen3 force-pushed the component-type branch 2 times, most recently from a3c4ca9 to de94fe3 Compare August 19, 2026 04:36
@centau

centau commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

New revision to the example cases:

The unknown_component type is useless because it cannot interact with the registry at all.
I'm considering removing unknown_component and instead adding a type function read<> which applies read semantics to a read<component<T>> in the same way that { read T } does to { T } in luau

See latest comment

@centau

centau commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Something like this seems to work

--------------------------------------------------------------------------------
-- component type candidates
--------------------------------------------------------------------------------

--type component<T> = { read __ctype: T }

export type component<T> = { __ctype: T }

-- type component<T> = T

-- type function component(T: type): type
--     local key = types.singleton("__ctype")
--     local t = types.newtable()

--     if T.tag == "unknown" then
--         t:setreadproperty(key, T)
--     else
--         t:setproperty(key, T)
--     end

--     return t
-- end

-- todo: needs to handle case where a union of components are given
-- todo: needs to handle cases where a component is not passed (give proper error messages)
type function read(component: type): type
    assert(component.tag == "table")
    
    local key = types.singleton("__ctype")

    local value = component:readproperty(key)
    assert(value)

    local readonly_component = types.newtable()
    readonly_component:setreadproperty(key, value)
    return readonly_component
end

--------------------------------------------------------------------------------
-- test functions
--------------------------------------------------------------------------------

-- Registry:clear()
local function clear(...: read<component<unknown>>)
end

-- Registry:set()
local function set<T>(ctype: component<T>, value: T)
end

-- Registry:get()
local function get<T>(ctype: read<component<T>>): T
    return nil :: any
end

-- hypothetical function
local function serdes(ctype: component<string>|component<number>): buffer
    return nil :: any
end

-- hypothetical function
local function serdes2(ctype: read<component<string|number>>): buffer
    return nil :: any
end

--------------------------------------------------------------------------------
-- component definitions
--------------------------------------------------------------------------------

local function component<T>(): component<T>

local c = {
    number = component<<number>>(),
    string = component<<string>>(),
    boolean = component<<boolean>>(),
    unknown = component<<unknown>>(),
    number_or_string = component<<number|string>>(),
}

--------------------------------------------------------------------------------
-- test cases
--------------------------------------------------------------------------------

-- SHOULD PASS
clear(c.number, c.string)
set(c.number, 1)
set(c.unknown, 1)
set(c.number_or_string, 1)
serdes(c.number)
serdes2(c.number)
local function get_test(ctype: read<component<unknown>>): unknown
    return get(ctype) -- should pass
end
get_test(c.unknown)
get_test(c.number)

-- SHOULD ERROR
set(c.number, "")
local function set_test(ctype: read<component<number>>)
    set(ctype, 1) -- should error
end

Notable changes are that APIs that promise not to mutate a component, must be typed to accept read<component<T>>, this will affect Registry:get(), Registry:has(), Registry:clear(), to name a few

@HarryXChen3

Copy link
Copy Markdown
Contributor Author

Couple thoughts..

read<> doesn't work

The type of registry:get with read<> is registry:get<T>(ctype: read<component<T>>): T, which does not allow T to be inferred:

type component<T> = { __ctype: T }
type ro<T> = { read __ctype: T }
 
-- stripped down version of read<> above
type function read(c: type): type
    local key = types.singleton("__ctype")
    local t = types.newtable()
    t:setreadproperty(key, c:readproperty(key))
    return t
end
 
local N = (nil :: any) :: component<number>
 
local function getA<T>(c: ro<T>): T return nil :: any end
local function getB<T>(c: read<component<T>>): T return nil :: any end
 
local x: number = getA(N) -- OK
local y: number = getB(N) -- TypeError: Expected this to be 'number', but got 'unknown'

My assumption is that the type function is unable to reduce a generic type T somehow, and falls back to unknown

readonly_component<T>

I propose the following instead:

export type component<T> = { __ctype: T, read __component: true }
export type readonly_component<T> = { read __ctype: T, read __component: true }
export type any_component = readonly_component<unknown>

why the read __component: true?

Take the following example:

export type readonly_component<T> = { read __ctype: T }
local function clear(...: readonly_component<unknown>)
end

clear("this is clearly not valid") -- OK
clear({x = 1}) -- OK

With the extra __component field:

export type readonly_component<T> = { read __ctype: T, read __component: true }
local function clear(...: readonly_component<unknown>)

end

-- TypeError: Expected this to be 'readonly_component<unknown>', but got 'string'; 
--   in the metatable portion, accessing `__index` results in `typeof(string)`,
--   which is not a subtype of `readonly_component<unknown>`
clear("this is clearly not valid")

-- TypeError: Table type '{ x: number }' not compatible with type
--   'readonly_component<unknown>' because the former is missing field '__component'
clear({x = 1})

I think this happens because a readonly property of unknown can be satisfied by any table (and strings are tables), since missing fields are treated as the nil type, and nil can populate unknown

how to type each method?

If a function consumes a T (and exactly T), then it must take component<T>
If a function does not consume T, then it may take readonly_component<T>

type why
set, insert, patch, find, copy component<T> takes T
storage component<T> Pool.values is writable
view component<A> View:patch() writes values
add, get, try_get, on_add, on_change readonly_component<T> only produces T
track, group readonly_component<A> iteration only
has, remove, clear, is_tag, :exclude any_component T unused

storage and view are exceptions to the rule above as they look like reads but return values that can later
write

@centau

centau commented Aug 19, 2026

Copy link
Copy Markdown
Owner

I think read<> should be looked into more to see if it's possible before going with this

Also find should be read only

__component: true is good

@HarryXChen3

HarryXChen3 commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor Author

find can't use readonly_component<T>

export type component<T> = { __ctype: T, read __component: true }
export type readonly_component<T> = { read __ctype: T, read __component: true }
export type any_component = readonly_component<unknown>

local function find<T>(ctype: readonly_component<T>, value: T): number?
    return 0
end

local A = (nil :: any) :: component<number>

find(A, "hello") -- OK

Technically, it is legal (in the sense that it is type-safe) to do find(A, "hello"), you will always get nil - but I think it's better if we make it explicitly illegal by using component<T> as usage of find in that manner is (imo) always a bug

local function find<T>(ctype: component<T>, value: T): number?
    return 0
end

local A = (nil :: any) :: component<number>

find(A, "hello") -- TypeError: Expected this to be 'number', but got 'string'
find(A, 1) -- OK

Alternatives considered

  1. (<T>(ctype: component<T>, value: T) -> number?) & (<T>(ctype: readonly_component<T>, value: T) -> nil) doesn't work
    local _: nil = find(A, "hello")
    
    -- TypeError: Calling function (<T>(component<T>, T) -> number?) & (<T>(readonly_component<T>, T) -> nil) with
    --     argument pack component<number>, number is ambiguous.
    local _: number? = find(A, 1)
  2. (<T>(component<T>, T) -> number?) & ((any_component, unknown) -> nil) doesn't work
    local _: nil = find(A, "hello")
    
    -- TypeError: Calling function ((any_component, unknown) -> nil) & (<T>(component<T>, T) -> number?)
    --     with argument pack component<number>, number is ambiguous.
    local _: number? = find(A, 1)

@HarryXChen3

HarryXChen3 commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Updates ecr to use component<T> and read_component<T> and adds typechecking regression tests.

Looked into keeping the read<component<T>> type function, but Luau type functions cannot infer T backward from their output (a limitation of the design itself). The new solver thus falls back to unknown, which is useless in our case.

Docs

Updated docs as well, couple things looked like they were missing from prior changes so went ahead and added those, but happy to back those out if desired.. also linked already existing wiki pages into the public docs, not sure if they were intentionally left out?

Typechecking tests

Adds typechecking testing infra with a positive fixture and an @error-annotated negative fixture.

Example passing output

typechecking
PASS│ typechecks
PASS│ fails typechecking (19)
...
PASS│ fails typechecking (96)

25/25 test cases passed.
0 fails

Example failing output

typechecking
FAIL│ typechecks
   ./tests.luau(32,20): TypeError: Expected this to be 'number', but got 'string'
FAIL│ fails typechecking (20)
         reg:set(e, Number, 1) -- @error
PASS│ fails typechecking (22)
...
PASS│ fails typechecking (99)

24/26 test cases passed.
2 fails

@HarryXChen3
HarryXChen3 marked this pull request as ready for review August 23, 2026 00:10
@HarryXChen3
HarryXChen3 requested a review from centau August 23, 2026 00:10

@centau centau left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I don't think wiki section is needed.

A full type testing setup seems overkill, I would of just had a file of a few cases that is occasionally manually inspected. But we can keep it

Comment thread docs/wiki/component-types.md Outdated
@HarryXChen3

HarryXChen3 commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Component Types

ECR components are runtime ids, but their exported types carry a phantom value type so Luau can check how each component is used.

local Health = ecr.component<<number>>()
local Name = ecr.component<<string>>()

ecr.component can also infer the value type from a constructor:

local Health = ecr.component(function()
    return 100
end) -- component<number>

component<T> and read_component<T>

component<T> is the mutable component type. Its phantom __ctype property is writable, making the type invariant in T. This prevents the new solver from widening the value type to make an invalid call succeed:

registry:set(entity, Health, 100)       -- OK
registry:set(entity, Health, "healthy") -- type error

Why component<T> is needed

Older ECR declarations represented a component type as an unconstrained generic value:

set: <T>(self: Registry, entity: entity, ctype: T, value: T) -> ()

With the new solver, the call below can infer T as number | string, which is valid for that declaration but not for the ECS operation:

registry:set(entity, Health, "healthy")
-- set<number | string>(entity, Health, "healthy")

component<T> fixes this because its writable phantom property makes the component's value type invariant. component<number> cannot be used where component<number | string> is required, so T is fixed to number by Health and the invalid value is rejected.

read_component<T> is the read-only view. Its phantom __ctype property is read-only, making it covariant in T. APIs that only read or produce a value accept it: add, get, try_get, signals, track, and group.

Why read_component<T> is needed

Functions sometimes need to accept a component without knowing its value type. For example, a helper that only reads a component can safely accept every kind of component:

local function read_unknown(ctype: ecr.read_component<unknown>): unknown
    return registry:get(entity, ctype)
end

read_unknown(Health) -- OK

The same helper must not be able to write through its erased component. If it accepted component<unknown>, it could call set with any value, corrupting a component<number>. read_component<unknown> prevents that conversion:

local function unsafe(ctype: ecr.read_component<unknown>)
    registry:set(entity, ctype, "not allowed") -- type error
end

component<T> is a subtype of read_component<T>, so concrete components can still be passed to read-only APIs while their precise T remains inferable.

Choosing a component parameter type

Use component<T> if the function consumes a T or returns a writable path to one. Use read_component<T> if it only reads or produces a T.

Parameter use Type Examples
Consumes a value or exposes writable values component<T> set, insert, patch, find, copy, storage, view
Only reads or produces values read_component<T> add, get, try_get, on_add, on_change, track, group
Only checks component identity read_component<unknown> has, remove, clear, is_tag, query exclusions

storage and view are deliberately in the first row: even though they read component data initially, Pool.values and View:patch() provide later writable access.

The component marker

Both types include a read-only __component: true marker. It has no runtime representation: components remain plain ids. At type-check time, the marker prevents an erased read_component<unknown> from accepting arbitrary tables or strings.

Why this is not a type function

A type function such as read<component<T>> looks like it could derive the read-only view. In practice, user-defined type functions are evaluated forward: when T is still unknown, the new solver cannot reverse the function to infer it from an argument. It falls back to unknown, losing the precise result type of reads.

Keeping read_component<T> as a plain alias preserves inference.

Known limitation

ecr.name accepts a table of named components. Luau does not check named table literal fields against a string indexer, so a table literal's values cannot be fully validated there. The indexer is still needed to disambiguate ecr.name's table and lookup overloads.

@HarryXChen3
HarryXChen3 requested a review from centau September 1, 2026 01:03

@centau centau left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

LGTM

@centau
centau merged commit b5be8a9 into centau:main Sep 3, 2026
1 check passed
@HarryXChen3
HarryXChen3 deleted the component-type branch September 3, 2026 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants