Skip to content

Conversation

@mmpestorich
Copy link
Contributor

@mmpestorich mmpestorich commented Jan 19, 2026

Summary

This PR adds support for extracting infer types from object literal patterns in conditional type expressions. This enables patterns like:

---@alias ExtractFoo<T> T extends { foo: infer F } and F or never

Motivation

This feature was developed to solve a specific use case that could not be achieved with existing functionality.

The Specific Use Case: Custom Class System with Constructor Fields

I maintain a lightweight single-inheritance class system for Lua/Neovim that uses a constructor field pattern rather than the @overload pattern:

-- A simplified version of our existing class system:

---@class Class<T>
---@overload fun(...: any): T <- where any is parameters from Object.constructor(self, ...) below
---@field name string The class name
---@field members? T The prototype containing instance methods
---@field superclass? Class The parent class, if any

---@class Object<T>
---@field class Class<T> The class this instance was created from
---@field super? table Proxy for calling parent class methods
---@field constructor? fun(self: T, ...: any) Constructor called during instantiation

---@class ClassBuilder<T>: Class<T>
---@overload fun(members: Partial<T>): Class<T>

---@generic T
---@param `T` instance name
---@return ClassBuilder<T>
local function class(name)
    -- returns a class builder
    -- the class builder can then be called to define or extend and define a class
end

-- Usage pattern:

---@class MyObject: Object<MyClass>
---@field name string
---@field count number
---@field constructor fun(self: self, name: string)  -- Constructor as a FIELD

local MyObjectClass = class('MyObject')({
  name = '',
  count = 0,
  constructor = function(self, name)
    self.name = name or 'default'
  end,
})

local obj = MyObjectClass('hello')  -- Instantiation: calls the class overload and delegates to the instance types constructor function
                                    -- obj type should be `MyObject` but can't be inferred without an additional @type annotation

The goal: I wanted to create a ConstructorParameters<T> type alias that could extract the parameter types from the constructor field, enabling type-safe generic factory functions:

---@return ClassBuilder<T, ConstructorParameters<T>>
local function class(name) ... end

What Was Tried (Existing Approach That Failed)

@[constructor(...)] and ConstructorParameters<T> with new keyword

The existing pattern from Issue #785 / PR #786:

-- The builtin:
---@alias ConstructorParameters<T> T extends new (fun(...: infer P): any) and P or never

---@generic T
---@[constructor('constructor', 'Object', false, false)]
---@param `T` instance name
---@return ClassBuilder<T, ConstructorParameters<T>>
local function class(name) end

Why it failed: This relies on the new keyword which looks for @overload on the instance type. My class system doesn't have an overload on the instance type. It defines constructors as @field constructor fun(...) on the instance type. The @overload fun(...) on the class type is simply an instance factory that delegates to the instance's constructor function.

What Was Needed

A way to match against the shape of an object and extract types from its fields:

---@alias ConstructorParams<T> T extends { constructor: fun(self: any, ...: infer P) } and P or never

This pattern says: "If T has a constructor field that is a function, extract the parameter types from that function."

The Solution

This PR enables exactly that pattern. Now the following works:

---@alias ConstructorParams<T> T extends { constructor: fun(self: any, ...: infer P) } and P or never

---@class Class<T>
---@overload fun(...: ConstructorParams<T>...): T
---@field name string The class name
---@field members? T The prototype containing instance methods
---@field superclass? Class The parent class, if any

---@class Object<T>
---@field class Class<T> The class this instance was created from
---@field super? table Proxy for calling parent class methods
---@field constructor? fun(self: T, ...: any) Constructor called during instantiation

---@class ClassBuilder<T,P>: Class<T>
---@overload fun(members: Partial<T>): Class<T>

---@class MyObject: Object<MyClass>
---@field name string
---@field count number
---@field constructor fun(self: self, name: string) 

local MyObjectClass = class('MyObject')({ ... })

local obj = MyObjectClass('hello')  -- obj type is now `MyObject`

Changes

Files Modified

  1. crates/emmylua_code_analysis/src/semantic/generic/instantiate_type/mod.rs

    • Added LuaType::Object case to collect_infer_assignments() function
    • Implemented three helper functions:
      • collect_infer_from_object_to_object() - matches Object type to Object pattern
      • collect_infer_from_class_to_object() - matches class/Ref type to Object pattern
      • collect_infer_from_table_to_object() - matches TableConst to Object pattern (limited support)
  2. crates/emmylua_code_analysis/src/compilation/test/object_infer_test.rs (new file)

    • 7 test cases covering various object pattern matching scenarios
  3. crates/emmylua_code_analysis/src/compilation/test/mod.rs

    • Added module declaration for new test file

Implementation Details

The implementation follows the existing pattern for DocFunction and Generic type matching in collect_infer_assignments(). When the pattern is an Object type, it:

  1. Iterates over the pattern's fields
  2. Looks up corresponding fields in the source type (using find_members_with_key for class types)
  3. Recursively collects infer assignments from matching fields
  4. Returns false if a required field with infer is missing (triggering the false branch of the conditional)

Test Cases

All 7 new tests pass, plus all 530+ existing tests:

Test Description
test_simple_infer_through_generic_func Baseline: verifies basic infer works
test_object_literal_infer_basic Extract field type from object type
test_object_literal_infer_from_class Extract field type from class
test_object_literal_infer_constructor_params Extract function params from constructor field
test_object_literal_infer_nested Extract from nested object patterns
test_object_literal_infer_no_match Returns never when field missing
test_object_literal_infer_function_field Extract function type from field

Key Test: Constructor Parameters Extraction

---@alias ConstructorParams<T> T extends { constructor: fun(self: any, ...: infer P): any } and P or never

---@class Widget
---@field constructor fun(self: Widget, name: string, width: number): Widget

---@generic T
---@param v T
---@return ConstructorParams<T>
function getParams(v) end

---@type Widget
local widget

C = getParams(widget)  -- C is typed as (string, number) ✓

Known Limitations

  • Inline table literals (TableConst): Member lookup for inline table literals (e.g., extractFoo({ foo = "hello" })) has limited support. The feature works best with typed variables (---@type { foo: string }) or class types (---@class).

AI-Generated Disclosure

This implementation was generated with assistance from Claude (Anthropic). The code follows the existing patterns in the codebase and all tests pass.

I personally have limited experience with rust but thought this pattern may be useful to others as it's not specific to Lua class-like implementations.

Related

Checklist

  • Code compiles without errors
  • All existing tests pass
  • New tests added for the feature
  • Code formatted with cargo fmt
  • Implementation follows existing code patterns

@gemini-code-assist
Copy link

Summary of Changes

Hello @mmpestorich, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly extends the generic type inference capabilities by allowing "infer" types to be extracted from object literal patterns in conditional type expressions. This enhancement provides greater flexibility for defining complex types, particularly benefiting custom class systems that utilize field-based constructors, by enabling precise extraction of constructor parameters for type-safe generic factory functions.

Highlights

  • Object Literal Pattern Matching: Introduced support for extracting "infer" types from object literal patterns within conditional type expressions.
  • Enhanced Type Inference: Enables more flexible type definitions, specifically allowing extraction of parameter types from "constructor" fields in custom class systems, which was previously not possible with "new" keyword-based inference.
  • Core Logic Expansion: The "collect_infer_assignments" function now handles "LuaType::Object" patterns, delegating to new helper functions for matching against object, class, and table constant source types.
  • Variadic Function Handling: Improved "infer P" handling for "DocFunction" to correctly distinguish between truly variadic functions (preserving base type) and named parameters (wrapped in a tuple for consistent spreading).
  • Comprehensive Testing: Added 7 new dedicated test cases ("object_infer_test.rs") to validate various scenarios of object literal pattern inference, ensuring robustness and correctness.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a powerful feature: inferring types from object literal patterns in conditional types. The implementation is well-structured, adding new logic to handle object, class, and table constant types during type inference. The accompanying tests are thorough and cover a good range of scenarios.

I've identified an opportunity to reduce code duplication in the new helper functions, which would improve maintainability. I've also suggested adding a test case to explicitly cover the known limitation with inline table literals, which will help document the current behavior and serve as a baseline for future work. Overall, this is a great addition to the type system.

Comment on lines +1 to +231

#[test]
fn test_object_literal_infer_nested() {
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@alias ExtractNested<T> T extends { outer: { inner: infer I } } and I or never

---@generic T
---@param v T
---@return ExtractNested<T>
function extractNested(v) end

---@type { outer: { inner: boolean } }
local nested

D = extractNested(nested)
"#,
);

let d_ty = ws.expr_ty("D");
assert_eq!(ws.humanize_type(d_ty), "boolean");
}

#[test]
fn test_object_literal_infer_no_match() {
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@alias ExtractFoo<T> T extends { foo: infer F } and F or never

---@generic T
---@param v T
---@return ExtractFoo<T>
function extractFoo(v) end

---@type { bar: string }
local noFoo

E = extractFoo(noFoo)
"#,
);

let e_ty = ws.expr_ty("E");
assert_eq!(ws.humanize_type(e_ty), "never");
}

#[test]
fn test_object_literal_infer_function_field() {
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@alias ExtractCallback<T> T extends { callback: infer C } and C or never

---@generic T
---@param v T
---@return ExtractCallback<T>
function extractCallback(v) end

---@type { callback: fun(x: number): string }
local obj

F = extractCallback(obj)
"#,
);

let f_ty = ws.expr_ty("F");
assert_eq!(ws.humanize_type(f_ty), "fun(x: number) -> string");
}

#[test]
fn test_object_literal_infer_true_variadic_params() {
// Test that true variadic functions (fun(self, ...: T)) preserve variadic behavior
// This should NOT be wrapped in a tuple - it should stay as the base type
let mut ws = VirtualWorkspace::new();
ws.def(
r#"
---@alias ExtractVariadic<T> T extends { handler: fun(self: any, ...: infer P): any } and P or never

---@class VariadicWidget
---@field handler fun(self: VariadicWidget, ...: string): VariadicWidget

---@generic T
---@param v T
---@return ExtractVariadic<T>
function getVariadicType(v) end

---@type VariadicWidget
local widget

V = getVariadicType(widget)
"#,
);

let v_ty = ws.expr_ty("V");
// True variadic should return the base type (not wrapped in tuple)
// so that variadic spreading continues to work as expected
assert_eq!(ws.humanize_type(v_ty), "string");
}
}

Choose a reason for hiding this comment

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

medium

The new test suite is quite comprehensive. However, the PR description mentions a known limitation with inline table literals (TableConst). It would be beneficial to add a test case that specifically covers this scenario, for example, extractFoo({ foo = "hello" }). This would document the current behavior and provide a baseline for future improvements. Here is a suggestion for such a test:

    #[test]
    fn test_object_literal_infer_from_inline_table() {
        let mut ws = VirtualWorkspace::new();
        ws.def(
            r#"
            ---@alias ExtractFoo<T> T extends { foo: infer F } and F or never

            ---@generic T
            ---@param v T
            ---@return ExtractFoo<T>
            function extractFoo(v) end

            G = extractFoo({ foo = "hello" })
            "#,
        );

        let g_ty = ws.expr_ty("G");
        assert_eq!(ws.humanize_type(g_ty), "string");
    }

Comment on lines +791 to +844
/// Match class/ref type to object pattern by looking up class members
fn collect_infer_from_class_to_object(
db: &DbIndex,
type_id: &LuaTypeDeclId,
pattern_object: &LuaObjectType,
assignments: &mut HashMap<String, LuaType>,
) -> bool {
let pattern_fields = pattern_object.get_fields();
let source_type = LuaType::Ref(type_id.clone());

for (key, pattern_field_ty) in pattern_fields {
if let Some(member_infos) = find_members_with_key(db, &source_type, key.clone(), false) {
if let Some(member_info) = member_infos.first() {
if !collect_infer_assignments(db, &member_info.typ, pattern_field_ty, assignments) {
return false;
}
} else if contains_conditional_infer(pattern_field_ty) {
return false;
}
} else if contains_conditional_infer(pattern_field_ty) {
return false;
}
}

true
}

/// Match table constant to object pattern by looking up table members
fn collect_infer_from_table_to_object(
db: &DbIndex,
table_id: &crate::InFiled<rowan::TextRange>,
pattern_object: &LuaObjectType,
assignments: &mut HashMap<String, LuaType>,
) -> bool {
let pattern_fields = pattern_object.get_fields();
let source_type = LuaType::TableConst(table_id.clone());

for (key, pattern_field_ty) in pattern_fields {
if let Some(member_infos) = find_members_with_key(db, &source_type, key.clone(), false) {
if let Some(member_info) = member_infos.first() {
if !collect_infer_assignments(db, &member_info.typ, pattern_field_ty, assignments) {
return false;
}
} else if contains_conditional_infer(pattern_field_ty) {
return false;
}
} else if contains_conditional_infer(pattern_field_ty) {
return false;
}
}

true
}

Choose a reason for hiding this comment

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

medium

The functions collect_infer_from_class_to_object and collect_infer_from_table_to_object are almost identical, leading to code duplication. Their logic can be extracted into a single helper function that takes source_type as a parameter. This would improve maintainability.

Additionally, the member lookup logic within both functions can be simplified by chaining .as_deref() and .and_then() to avoid nested if let statements and a redundant check for contains_conditional_infer.

While I can't suggest adding a new helper function directly, I've refactored both functions to be more concise. You could then easily extract the common logic.

/// Match class/ref type to object pattern by looking up class members
fn collect_infer_from_class_to_object(
    db: &DbIndex,
    type_id: &LuaTypeDeclId,
    pattern_object: &LuaObjectType,
    assignments: &mut HashMap<String, LuaType>,
) -> bool {
    let pattern_fields = pattern_object.get_fields();
    let source_type = LuaType::Ref(type_id.clone());

    for (key, pattern_field_ty) in pattern_fields {
        if let Some(member_info) = find_members_with_key(db, &source_type, key.clone(), false)
            .as_deref()
            .and_then(|infos| infos.first())
        {
            if !collect_infer_assignments(db, &member_info.typ, pattern_field_ty, assignments) {
                return false;
            }
        } else if contains_conditional_infer(pattern_field_ty) {
            return false;
        }
    }

    true
}

/// Match table constant to object pattern by looking up table members
fn collect_infer_from_table_to_object(
    db: &DbIndex,
    table_id: &crate::InFiled<rowan::TextRange>,
    pattern_object: &LuaObjectType,
    assignments: &mut HashMap<String, LuaType>,
) -> bool {
    let pattern_fields = pattern_object.get_fields();
    let source_type = LuaType::TableConst(table_id.clone());

    for (key, pattern_field_ty) in pattern_fields {
        if let Some(member_info) = find_members_with_key(db, &source_type, key.clone(), false)
            .as_deref()
            .and_then(|infos| infos.first())
        {
            if !collect_infer_assignments(db, &member_info.typ, pattern_field_ty, assignments) {
                return false;
            }
        } else if contains_conditional_infer(pattern_field_ty) {
            return false;
        }
    }

    true
}

@CppCXY
Copy link
Member

CppCXY commented Jan 21, 2026

please fix the codestyle check

@xuhuanzy
Copy link
Member

Rename the test file to generic_infer_test.

Adds support for extracting types from object literal patterns in
conditional types, enabling patterns like:
  T extends { foo: infer F } and F or never

This allows extracting field types from classes and object types,
including function signatures for constructor parameter extraction.

Also fixes single-parameter variadic infer to return a tuple for
consistent spreading behavior (named params vs true variadics).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mmpestorich mmpestorich force-pushed the feature/object-literal-matching branch from 3cbf836 to b396ee8 Compare January 22, 2026 05:03
@CppCXY CppCXY merged commit 91eb2a8 into EmmyLuaLs:main Jan 23, 2026
22 checks passed
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.

3 participants