Repository: dagster-io/dagster
Pull request #28330: [resolved] field resolution update
Author: login: alangenfeld
Created: 2025-03-07T22:02:18Z | Merged: 2025-03-11T22:22:07Z

This updates renames `FieldResolver` to `Resolver` and adds

* `Resolver.from_annotation()` takes care of resolving potentially nested `ResolvedFrom` types from their associated `ResolvableModel`
* `Resolver.from_resolved_kwargs(KwargsCls)` takes care of resolving potentially nested target type from the associated ResolvableModel type.
* `Resolver.from_template_injection()` marks fields that expect to be resolved via template injection. 

Additionally
* deriving field resolvers fails on unexpected types instead of assuming things will work.
* `ResolvedKwargs` is now generic over both its `TModel` source and its `T` target type to facilitate nested resolution.

## How I Tested These Changes

updated and existing tests



Diff:
diff --git a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/custom-schema-resolution.py b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/custom-schema-resolution.py
index 79298abb69a5d..9b36f00e73b76 100644
--- a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/custom-schema-resolution.py
+++ b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/custom-schema-resolution.py
@@ -4,10 +4,10 @@
 from dagster_components import (
     Component,
     ComponentLoadContext,
-    FieldResolver,
     ResolutionContext,
     ResolvableModel,
     ResolvedFrom,
+    Resolver,
 )
 
 import dagster as dg
@@ -31,6 +31,6 @@ def resolve_api_key(
 class MyComponent(Component, ResolvedFrom[MyComponentModel]):
     # FieldResolver specifies a function used to map input matching the schema
     # to a value for this field
-    api_client: Annotated[MyApiClient, FieldResolver(resolve_api_key)]
+    api_client: Annotated[MyApiClient, Resolver(resolve_api_key)]
 
     def build_defs(self, load_context: ComponentLoadContext) -> dg.Definitions: ...
diff --git a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-blueprint.py b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-blueprint.py
index a4dcf7f0ac339..84fb5c131a463 100644
--- a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-blueprint.py
+++ b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-blueprint.py
@@ -14,7 +14,7 @@
     scaffold_component_yaml,
 )
 from dagster_components.blueprint import scaffold_with
-from dagster_components.resolved.core_models import AssetSpecSequenceField
+from dagster_components.resolved.core_models import ResolvedAssetSpec
 from dagster_components.resolved.model import ResolvableModel, ResolvedFrom
 
 import dagster as dg
@@ -55,7 +55,7 @@ class ShellCommand(Component, ResolvedFrom[ShellCommandModel]):
     """Models a shell script as a Dagster asset."""
 
     script_path: str
-    asset_specs: AssetSpecSequenceField
+    asset_specs: Sequence[ResolvedAssetSpec]
 
     def build_defs(self, load_context: ComponentLoadContext) -> dg.Definitions:
         resolved_script_path = Path(load_context.path, self.script_path).absolute()
diff --git a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-build-defs.py b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-build-defs.py
index eb7cca6e87341..26d1e82fab3a3 100644
--- a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-build-defs.py
+++ b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-build-defs.py
@@ -10,7 +10,7 @@
     ResolvableModel,
     ResolvedFrom,
 )
-from dagster_components.resolved.core_models import AssetSpecSequenceField
+from dagster_components.resolved.core_models import ResolvedAssetSpec
 
 import dagster as dg
 
@@ -25,7 +25,7 @@ class ShellCommand(Component, ResolvedFrom[ShellCommandModel]):
     """Models a shell script as a Dagster asset."""
 
     script_path: str
-    asset_specs: AssetSpecSequenceField
+    asset_specs: Sequence[ResolvedAssetSpec]
 
     def build_defs(self, load_context: ComponentLoadContext) -> dg.Definitions:
         resolved_script_path = Path(load_context.path, self.script_path).absolute()
diff --git a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-class-defined.py b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-class-defined.py
index f1e9394b65280..41f82a1cb60b0 100644
--- a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-class-defined.py
+++ b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-class-defined.py
@@ -8,7 +8,7 @@
     ResolvableModel,
     ResolvedFrom,
 )
-from dagster_components.resolved.core_models import AssetSpecSequenceField
+from dagster_components.resolved.core_models import ResolvedAssetSpec
 
 import dagster as dg
 
@@ -21,6 +21,6 @@ class ShellCommandModel(ResolvableModel):
 @dataclass
 class ShellCommand(Component, ResolvedFrom[ShellCommandModel]):
     script_path: str
-    asset_specs: AssetSpecSequenceField
+    asset_specs: Sequence[ResolvedAssetSpec]
 
     def build_defs(self, load_context: ComponentLoadContext) -> dg.Definitions: ...
diff --git a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-custom-scope.py b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-custom-scope.py
index 3d83920f9fd85..06e8fa771fba1 100644
--- a/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-custom-scope.py
+++ b/examples/docs_beta_snippets/docs_beta_snippets/guides/components/shell-script-component/with-custom-scope.py
@@ -11,7 +11,7 @@
     ResolvableModel,
     ResolvedFrom,
 )
-from dagster_components.resolved.core_models import AssetSpecSequenceField
+from dagster_components.resolved.core_models import ResolvedAssetSpec
 
 import dagster as dg
 
@@ -24,7 +24,7 @@ class ShellCommandModel(ResolvableModel):
 @dataclass
 class ShellCommand(Component, ResolvedFrom[ShellCommandModel]):
     script_path: str
-    asset_specs: AssetSpecSequenceField
+    asset_specs: Sequence[ResolvedAssetSpec]
 
     @classmethod
     def get_additional_scope(cls) -> Mapping[str, Any]:
diff --git a/python_modules/dagster-test/dagster_test/components/complex_schema_asset.py b/python_modules/dagster-test/dagster_test/components/complex_schema_asset.py
index 2141dfb9fed35..ea968c185f97f 100644
--- a/python_modules/dagster-test/dagster_test/components/complex_schema_asset.py
+++ b/python_modules/dagster-test/dagster_test/components/complex_schema_asset.py
@@ -13,7 +13,7 @@
     OpSpecModel,
 )
 from dagster_components.resolved.metadata import ResolvableFieldInfo
-from dagster_components.resolved.model import ResolvableModel, ResolvedFrom
+from dagster_components.resolved.model import ResolvableModel, ResolvedFrom, Resolver
 from pydantic import Field
 
 
@@ -40,7 +40,7 @@ class ComplexAssetComponent(Component, ResolvedFrom[ComplexAssetModel]):
     op: Optional[OpSpecModel] = None
     asset_attributes: Optional[AssetAttributesModel] = None
     asset_post_processors: Annotated[
-        Optional[Sequence[AssetPostProcessor]], AssetPostProcessor.from_optional_seq
+        Optional[Sequence[AssetPostProcessor]], Resolver.from_annotation()
     ] = None
 
     def build_defs(self, context: ComponentLoadContext) -> Definitions:
diff --git a/python_modules/libraries/dagster-components/dagster_components/__init__.py b/python_modules/libraries/dagster-components/dagster_components/__init__.py
index 48f724c849e57..975f4bff64c5e 100644
--- a/python_modules/libraries/dagster-components/dagster_components/__init__.py
+++ b/python_modules/libraries/dagster-components/dagster_components/__init__.py
@@ -27,9 +27,9 @@
 )
 from dagster_components.resolved.metadata import ResolvableFieldInfo as ResolvableFieldInfo
 from dagster_components.resolved.model import (
-    FieldResolver as FieldResolver,
     ResolvableModel as ResolvableModel,
     ResolvedFrom as ResolvedFrom,
     ResolvedKwargs as ResolvedKwargs,
+    Resolver as Resolver,
 )
 from dagster_components.version import __version__ as __version__
diff --git a/python_modules/libraries/dagster-components/dagster_components/components/dbt_project/component.py b/python_modules/libraries/dagster-components/dagster_components/components/dbt_project/component.py
index 48cd43fe49e7e..271edff3616af 100644
--- a/python_modules/libraries/dagster-components/dagster_components/components/dbt_project/component.py
+++ b/python_modules/libraries/dagster-components/dagster_components/components/dbt_project/component.py
@@ -29,7 +29,7 @@
     ResolutionContext,
 )
 from dagster_components.resolved.metadata import ResolvableFieldInfo
-from dagster_components.resolved.model import FieldResolver, ResolvableModel, ResolvedFrom
+from dagster_components.resolved.model import ResolvableModel, ResolvedFrom, Resolver
 from dagster_components.utils import TranslatorResolvingInfo, get_wrapped_translator_class
 
 
@@ -65,15 +65,14 @@ def resolve_dbt(context: ResolutionContext, dbt: DbtCliResource) -> DbtCliResour
 class DbtProjectComponent(Component, ResolvedFrom[DbtProjectModel]):
     """Expose a DBT project to Dagster as a set of assets."""
 
-    dbt: Annotated[DbtCliResource, FieldResolver(resolve_dbt)]
-    op: Annotated[Optional[OpSpec], FieldResolver(OpSpec.from_optional)] = None
+    dbt: Annotated[DbtCliResource, Resolver(resolve_dbt)]
+    op: Annotated[Optional[OpSpec], Resolver.from_annotation()] = None
     # This requires from_parent because it access asset_attributes in the model
-    translator: Annotated[DagsterDbtTranslator, FieldResolver.from_model(resolve_translator)] = (
-        field(default_factory=DagsterDbtTranslator)
+    translator: Annotated[DagsterDbtTranslator, Resolver.from_model(resolve_translator)] = field(
+        default_factory=DagsterDbtTranslator
     )
     asset_post_processors: Annotated[
-        Optional[Sequence[AssetPostProcessor]],
-        FieldResolver(AssetPostProcessor.from_optional_seq),
+        Optional[Sequence[AssetPostProcessor]], Resolver.from_annotation()
     ] = None
     select: str = "fqn:*"
     exclude: Optional[str] = None
diff --git a/python_modules/libraries/dagster-components/dagster_components/components/pipes_subprocess_script_collection.py b/python_modules/libraries/dagster-components/dagster_components/components/pipes_subprocess_script_collection.py
index 513f0b2bff7b3..fa36ed0589f6e 100644
--- a/python_modules/libraries/dagster-components/dagster_components/components/pipes_subprocess_script_collection.py
+++ b/python_modules/libraries/dagster-components/dagster_components/components/pipes_subprocess_script_collection.py
@@ -1,6 +1,7 @@
 import shutil
 from collections.abc import Mapping, Sequence
 from dataclasses import dataclass
+from functools import cached_property
 from pathlib import Path
 from typing import TYPE_CHECKING, Annotated
 
@@ -11,9 +12,8 @@
 from dagster._core.pipes.subprocess import PipesSubprocessClient
 
 from dagster_components.core.component import Component, ComponentLoadContext
-from dagster_components.resolved.context import ResolutionContext
-from dagster_components.resolved.core_models import AssetSpecModel, AssetSpecSequenceField
-from dagster_components.resolved.model import FieldResolver, ResolvableModel, ResolvedFrom
+from dagster_components.resolved.core_models import AssetSpecModel, ResolvedAssetSpec
+from dagster_components.resolved.model import ResolvableModel, ResolvedFrom, Resolver
 
 if TYPE_CHECKING:
     from dagster._core.definitions.definitions_class import Definitions
@@ -27,36 +27,33 @@ class PipesSubprocessScriptModel(ResolvableModel):
 @dataclass
 class PipesSubprocessScript(ResolvedFrom[PipesSubprocessScriptModel]):
     path: str
-    assets: AssetSpecSequenceField
+    assets: Sequence[ResolvedAssetSpec]
 
 
 class PipesSubprocessScriptCollectionModel(ResolvableModel):
     scripts: Sequence[PipesSubprocessScriptModel]
 
 
-def resolve_specs_by_path(
-    context: ResolutionContext, model: PipesSubprocessScriptCollectionModel
-) -> Mapping[str, Sequence[AssetSpec]]:
-    # "A mapping from Python script paths to the assets that are produced by the script.",
-    return {
-        spec.path: spec.assets for spec in PipesSubprocessScript.from_seq(context, model.scripts)
-    }
-
-
 @dataclass
 class PipesSubprocessScriptCollectionComponent(
     Component, ResolvedFrom[PipesSubprocessScriptCollectionModel]
 ):
     """Assets that wrap Python scripts executed with Dagster's PipesSubprocessClient."""
 
-    specs_by_path: Annotated[
-        Mapping[str, Sequence[AssetSpec]], FieldResolver.from_model(resolve_specs_by_path)
-    ] = ...
+    scripts: Annotated[Sequence[PipesSubprocessScript], Resolver.from_annotation()]
+
+    @cached_property
+    def specs_by_path(self) -> Mapping[str, Sequence[AssetSpec]]:
+        return {script.path: script.assets for script in self.scripts}
 
     @staticmethod
     def introspect_from_path(path: Path) -> "PipesSubprocessScriptCollectionComponent":
-        path_specs = {str(path): [AssetSpec(path.stem)] for path in list(path.rglob("*.py"))}
-        return PipesSubprocessScriptCollectionComponent(specs_by_path=path_specs)
+        return PipesSubprocessScriptCollectionComponent(
+            [
+                PipesSubprocessScript(path=str(path), assets=[AssetSpec(path.stem)])
+                for path in list(path.rglob("*.py"))
+            ]
+        )
 
     def build_defs(self, context: "ComponentLoadContext") -> "Definitions":
         from dagster._core.definitions.definitions_class import Definitions
diff --git a/python_modules/libraries/dagster-components/dagster_components/components/sling_replication_collection/component.py b/python_modules/libraries/dagster-components/dagster_components/components/sling_replication_collection/component.py
index 2d0d8c639eb10..1c75880435bc7 100644
--- a/python_modules/libraries/dagster-components/dagster_components/components/sling_replication_collection/component.py
+++ b/python_modules/libraries/dagster-components/dagster_components/components/sling_replication_collection/component.py
@@ -30,7 +30,7 @@
     OpSpecModel,
 )
 from dagster_components.resolved.metadata import ResolvableFieldInfo
-from dagster_components.resolved.model import FieldResolver, ResolvableModel, ResolvedFrom
+from dagster_components.resolved.model import ResolvableModel, ResolvedFrom, Resolver
 from dagster_components.utils import TranslatorResolvingInfo, get_wrapped_translator_class
 
 SlingMetadataAddons: TypeAlias = Literal["column_metadata", "row_count"]
@@ -72,10 +72,8 @@ def resolve_translator(
 @dataclass
 class SlingReplicationSpecModel(ResolvedFrom["SlingReplicationModel"]):
     path: str
-    op: Annotated[Optional[OpSpec], FieldResolver(OpSpec.from_optional)]
-    translator: Annotated[
-        Optional[DagsterSlingTranslator], FieldResolver.from_model(resolve_translator)
-    ]
+    op: Annotated[Optional[OpSpec], Resolver.from_annotation()]
+    translator: Annotated[Optional[DagsterSlingTranslator], Resolver.from_model(resolve_translator)]
     include_metadata: list[SlingMetadataAddons]
 
 
@@ -122,13 +120,10 @@ def resolve_resource(
 class SlingReplicationCollectionComponent(Component, ResolvedFrom[SlingReplicationCollectionModel]):
     """Expose one or more Sling replications to Dagster as assets."""
 
-    resource: Annotated[SlingResource, FieldResolver.from_model(resolve_resource)] = ...
-    replications: Annotated[
-        Sequence[SlingReplicationSpecModel], FieldResolver(SlingReplicationSpecModel.from_seq)
-    ] = ...
+    resource: Annotated[SlingResource, Resolver.from_model(resolve_resource)] = ...
+    replications: Annotated[Sequence[SlingReplicationSpecModel], Resolver.from_annotation()] = ...
     asset_post_processors: Annotated[
-        Optional[Sequence[AssetPostProcessor]],
-        FieldResolver(AssetPostProcessor.from_optional_seq),
+        Optional[Sequence[AssetPostProcessor]], Resolver.from_annotation()
     ] = None
 
     def build_asset(
diff --git a/python_modules/libraries/dagster-components/dagster_components/resolved/core_models.py b/python_modules/libraries/dagster-components/dagster_components/resolved/core_models.py
index b5413e162dd10..bfdafece80177 100644
--- a/python_modules/libraries/dagster-components/dagster_components/resolved/core_models.py
+++ b/python_modules/libraries/dagster-components/dagster_components/resolved/core_models.py
@@ -18,10 +18,10 @@
 
 from dagster_components.resolved.context import ResolutionContext
 from dagster_components.resolved.model import (
-    FieldResolver,
     ResolvableModel,
     ResolvedFrom,
     ResolvedKwargs,
+    Resolver,
     resolve_fields,
 )
 
@@ -66,7 +66,7 @@ class OpSpec(ResolvedFrom["OpSpecModel"]):
     name: Optional[str] = None
     tags: Optional[dict[str, str]] = None
     backfill_policy: Annotated[
-        Optional[BackfillPolicy], FieldResolver.from_model(resolve_backfill_policy)
+        Optional[BackfillPolicy], Resolver.from_model(resolve_backfill_policy)
     ] = None
 
 
@@ -133,7 +133,7 @@ class AssetSpecModel(_ResolvableAssetAttributesMixin, ResolvableModel):
     key: str = Field(..., description="A unique identifier for the asset.")
 
 
-class SharedAssetKwargs(ResolvedKwargs["AssetAttributesModel"]):
+class SharedAssetKwargs(ResolvedKwargs["AssetAttributesModel", AssetSpec]):
     deps: Sequence[str]
     description: Optional[str]
     metadata: Mapping[str, Any]
@@ -143,29 +143,28 @@ class SharedAssetKwargs(ResolvedKwargs["AssetAttributesModel"]):
     owners: Sequence[str]
     tags: Mapping[str, str]
     kinds: Optional[Sequence[str]]
-    automation_condition: Optional[AutomationCondition]
+    automation_condition: Annotated[
+        Optional[AutomationCondition], Resolver.from_template_injection()
+    ]
 
 
 class AssetSpecKwargs(SharedAssetKwargs):
     key: Annotated[
         AssetKey,
-        FieldResolver.from_model(lambda context, schema: _resolve_asset_key(schema.key, context)),
+        Resolver.from_model(lambda context, schema: _resolve_asset_key(schema.key, context)),
     ]
 
 
 class AssetAttributesKwargs(SharedAssetKwargs):
     key: Annotated[
         Optional[AssetKey],
-        FieldResolver.from_model(
+        Resolver.from_model(
             lambda context, schema: _resolve_asset_key(schema.key, context) if schema.key else None
         ),
     ] = None
 
 
-AssetSpecSequenceField: TypeAlias = Annotated[
-    Sequence[AssetSpec],
-    FieldResolver(AssetSpecKwargs.resolver_fn(AssetSpec).from_seq),
-]
+ResolvedAssetSpec: TypeAlias = Annotated[AssetSpec, Resolver.from_resolved_kwargs(AssetSpecKwargs)]
 
 
 class AssetAttributesModel(_ResolvableAssetAttributesMixin, ResolvableModel):
@@ -188,7 +187,7 @@ def resolve_asset_attributes_to_mapping(
 
 
 ResolvedAssetAttributes: TypeAlias = Annotated[
-    Mapping[str, Any], FieldResolver(resolve_asset_attributes_to_mapping)
+    Mapping[str, Any], Resolver(resolve_asset_attributes_to_mapping)
 ]
 
 
@@ -246,4 +245,4 @@ def resolve_schema_to_post_processor(
 
 @dataclass
 class AssetPostProcessor(ResolvedFrom[AssetPostProcessorModel]):
-    fn: Annotated[PostProcessorFn, FieldResolver.from_model(resolve_schema_to_post_processor)]
+    fn: Annotated[PostProcessorFn, Resolver.from_model(resolve_schema_to_post_processor)]
diff --git a/python_modules/libraries/dagster-components/dagster_components/resolved/model.py b/python_modules/libraries/dagster-components/dagster_components/resolved/model.py
index c7a5492396f44..e68102e33ea8b 100644
--- a/python_modules/libraries/dagster-components/dagster_components/resolved/model.py
+++ b/python_modules/libraries/dagster-components/dagster_components/resolved/model.py
@@ -1,3 +1,4 @@
+from abc import ABC, abstractmethod
 from collections.abc import Mapping, Sequence
 from dataclasses import dataclass
 from typing import (
@@ -6,6 +7,7 @@
     Any,
     Callable,
     Generic,
+    Literal,
     Optional,
     TypeVar,
     Union,
@@ -15,7 +17,13 @@
 
 from dagster import _check as check
 from pydantic import BaseModel, ConfigDict
-from typing_extensions import Self
+from typing_extensions import TypeAlias
+
+try:
+    # this type only exists in python 3.10+
+    from types import UnionType  # type: ignore
+except ImportError:
+    UnionType = Union
 
 if TYPE_CHECKING:
     from dagster_components.resolved.context import ResolutionContext
@@ -31,7 +39,7 @@ class ResolvableModel(BaseModel):
 def get_model_type(
     resolved_from_type: type["ResolvedFrom"],
 ) -> type[ResolvableModel]:
-    """Returns the first generic type argument (TSchema) of the ResolvableFromSchema instance at runtime."""
+    """Returns the first generic type argument (TModel) of the ResolvedFrom subclass at runtime."""
     check.param_invariant(
         issubclass(resolved_from_type, ResolvedFrom),
         "resolvable_from_type",
@@ -41,40 +49,66 @@ def get_model_type(
         "resolvable_from_type",
     )
     for base in resolved_from_type.__orig_bases__:  # type: ignore
-        # Check if this base originates from ResolvableFromSchema
+        # Check if this base originates from ResolvedFrom
         origin = getattr(base, "__origin__", None)
         if origin is ResolvedFrom:
             type_args = get_args(base)
             if not type_args:
+                raise ValueError("ResolvedFrom base found but no generic type arguments present")
+            return type_args[0]
+
+    raise ValueError("No generic type arguments found in ResolvedFrom subclass")
+
+
+def get_resolved_kwargs_target_type(resolved_kwargs_type: type["ResolvedKwargs"]):
+    """Returns the generic type arguments (TModel, TObject) of a ResolvedKwargs subclass at runtime."""
+    check.param_invariant(
+        issubclass(resolved_kwargs_type, ResolvedKwargs),
+        "resolved_kwargs_type",
+    )
+    check.param_invariant(
+        hasattr(resolved_kwargs_type, "__orig_bases__"),
+        "resolved_kwargs_type",
+    )
+    for base in resolved_kwargs_type.__orig_bases__:  # type: ignore
+        # Check if this base originates from ResolvedFrom
+        origin = getattr(base, "__origin__", None)
+        if origin is ResolvedKwargs:
+            type_args = get_args(base)
+            if not type_args:
+                raise ValueError("ResolvedKwargs base found but no generic type arguments present")
+            if len(type_args) != 2:
                 raise ValueError(
-                    "ResolvableFromSchema base found but no generic type arguments present"
+                    f"ResolvedKwargs base found but has incorrect number of type arguments, expected 2 got {len(type_args)}"
                 )
-            return type_args[0]
+            return type_args[1]
 
-    raise ValueError("No generic type arguments found in ResolvableFromSchema subclass")
+    raise ValueError("No generic type arguments found in ResolvedKwargs subclass")
 
 
 T = TypeVar("T")
 
 
-class ResolutionResolverFn(Generic[T]):
-    def __init__(self, target_type: type[T], spec_type: type["ResolvedKwargs"]):
-        self.target_type = target_type
-        self.spec_type = spec_type
+class ResolvedFrom(Generic[TModel], ABC):
+    """Class which defines a type that can be resolved from the associated ResolvableModel."""
 
-    def from_model(self, context: "ResolutionContext", model: ResolvableModel) -> T:
-        return resolve_model_using_kwargs_cls(
-            model=model,
-            kwargs_cls=self.spec_type,
-            context=context,
-            target_type=self.target_type,
-        )
+
+class ResolvedKwargs(Generic[TModel, T], ABC):
+    """For cases where you can not inherit from ResolvedFrom on the desired target type,
+    ResolvedKwargs allows you to define an object which will be resolved from its associated
+    ResolvableModel and then passed unpacked as kwargs to create the target type.
+    """
+
+
+class _ModelResolver(Generic[T], ABC):
+    @abstractmethod
+    def resolve_from_model(self, context: "ResolutionContext", model: ResolvableModel) -> T: ...
 
     def from_seq(self, context: "ResolutionContext", model: Sequence[TModel]) -> Sequence[T]:
-        return [self.from_model(context, item) for item in model]
+        return [self.resolve_from_model(context, item) for item in model]
 
     def from_optional(self, context: "ResolutionContext", model: Optional[TModel]) -> Optional[T]:
-        return self.from_model(context, model) if model else None
+        return self.resolve_from_model(context, model) if model else None
 
     def from_optional_seq(
         self, context: "ResolutionContext", model: Optional[Sequence[TModel]]
@@ -82,30 +116,32 @@ def from_optional_seq(
         return self.from_seq(context, model) if model else None
 
 
-class ResolvedKwargs(Generic[TModel]):
-    @classmethod
-    def resolver_fn(cls, target_type: type) -> ResolutionResolverFn:
-        return ResolutionResolverFn(target_type=target_type, spec_type=cls)
+@dataclass(frozen=True)
+class _KwargsResolver(_ModelResolver[T]):
+    target_type: type[T]
+    kwargs_type: type["ResolvedKwargs"]
 
+    def resolve_from_model(self, context: "ResolutionContext", model: ResolvableModel) -> T:
+        return resolve_model_using_kwargs_cls(
+            model=model,
+            kwargs_cls=self.kwargs_type,
+            context=context,
+            target_type=self.target_type,
+        )
 
-class ResolvedFrom(ResolvedKwargs[TModel]):
-    @classmethod
-    def from_model(cls, context: "ResolutionContext", model: TModel) -> Self:
-        return resolve_model(model=model, resolvable_type=cls, context=context)
-
-    @classmethod
-    def from_optional(cls, context: "ResolutionContext", model: Optional[TModel]) -> Optional[Self]:
-        return cls.from_model(context, model) if model else None
 
-    @classmethod
-    def from_seq(cls, context: "ResolutionContext", model: Sequence[TModel]) -> Sequence[Self]:
-        return [cls.from_model(context.at_path(idx), item) for idx, item in enumerate(model)]
+@dataclass(frozen=True)
+class _DirectResolver(_ModelResolver[ResolvedFrom]):
+    target_type: type[ResolvedFrom]
 
-    @classmethod
-    def from_optional_seq(
-        cls, context: "ResolutionContext", model: Optional[Sequence[TModel]]
-    ) -> Optional[Sequence[Self]]:
-        return cls.from_seq(context, model) if model else None
+    def resolve_from_model(
+        self, context: "ResolutionContext", model: ResolvableModel
+    ) -> ResolvedFrom:
+        return resolve_model(
+            model=model,
+            resolvable_type=self.target_type,
+            context=context,
+        )
 
 
 @dataclass
@@ -118,12 +154,125 @@ class AttrWithContextFn:
     callable: Callable[["ResolutionContext", Any], Any]
 
 
-class FieldResolver:
-    """Contains information on how to resolve this field from a DSLSchema."""
+@dataclass(frozen=True)
+class _AutoResolve:
+    via: Optional[type[ResolvedKwargs]] = None
+
+
+class _ExpectedInjection: ...
+
+
+def _is_scalar(annotation):
+    if annotation in (int, float, str, bool, Any, type(None)):
+        return True
+
+    origin = get_origin(annotation)
+    args = get_args(annotation)
+
+    if origin in (Union, UnionType, list, Sequence, tuple, dict, Mapping) and all(
+        _is_scalar(arg) for arg in args
+    ):
+        return True
+
+    if origin is Annotated and any(isinstance(arg, _ExpectedInjection) for arg in args):
+        return True
+
+    if origin is Literal and all(_is_scalar(type(arg)) for arg in args):
+        return True
+
+    return False
+
+
+def _get_resolver(
+    annotation: type,
+    top_level_auto_resolve: Optional[_AutoResolve],
+) -> Optional[_ModelResolver]:
+    if top_level_auto_resolve and isinstance(annotation, type):
+        if top_level_auto_resolve.via and annotation is get_resolved_kwargs_target_type(
+            top_level_auto_resolve.via
+        ):
+            return _KwargsResolver(
+                kwargs_type=top_level_auto_resolve.via,
+                target_type=annotation,
+            )
+
+        if issubclass(annotation, ResolvedFrom):
+            return _DirectResolver(annotation)
+
+    origin = get_origin(annotation)
+    if origin is not Annotated:
+        return None
+
+    args = get_args(annotation)
+    auto_resolve = next((arg for arg in args if isinstance(arg, _AutoResolve)), None)
+    if not auto_resolve:
+        return None
+
+    if auto_resolve.via:
+        return _KwargsResolver(kwargs_type=auto_resolve.via, target_type=args[0])
+
+    resolved_from_cls = args[0]
+    if not issubclass(resolved_from_cls, ResolvedFrom):
+        check.failed("Can only annotate ResolvedFrom types with ResolveModel()")
+    return _DirectResolver(resolved_from_cls)
+
+
+def derive_field_resolver(annotation: Any, field_name: str) -> "Resolver":
+    if _is_scalar(annotation):
+        return Resolver.from_model(
+            lambda context, model: context.resolve_value(getattr(model, field_name))
+        )
+
+    origin = get_origin(annotation)
+    args = get_args(annotation)
+
+    top_level_auto_resolve = None
+    if origin is Annotated:
+        resolver = next((arg for arg in args if isinstance(arg, Resolver)), None)
+        if resolver:
+            return resolver
+
+        top_level_auto_resolve = next((arg for arg in args if isinstance(arg, _AutoResolve)), None)
+        resolver = _get_resolver(args[0], top_level_auto_resolve)
+        if resolver:
+            return Resolver(resolver.resolve_from_model)
+
+        origin = get_origin(args[0])
+        args = get_args(args[0])
+
+    if origin in (Union, UnionType) and len(args) == 2:
+        left_t, right_t = args
+        if right_t is type(None):
+            resolver = _get_resolver(left_t, top_level_auto_resolve)
+            if resolver:
+                return Resolver(resolver.from_optional)
+
+            elif get_origin(left_t) in (Sequence, tuple, list):
+                resolver = _get_resolver(get_args(left_t)[0], top_level_auto_resolve)
+                if resolver:
+                    return Resolver(resolver.from_optional_seq)
+
+    elif origin in (Sequence, tuple, list):
+        resolver = _get_resolver(args[0], top_level_auto_resolve)
+
+        if resolver:
+            return Resolver(resolver.from_seq)
+
+    check.failed(
+        f"Could not derive resolver for annotation {field_name}: {annotation}.\n"
+        "Field types are expected to either be simple serializable types such as "
+        "str, float, int, bool, list, etc or Annotated with an appropriate Resolver."
+    )
+
+
+class Resolver:
+    """Contains information on how to resolve a field from a ResolvableModel."""
 
     def __init__(
-        self, fn: Union[ParentFn, AttrWithContextFn, Callable[["ResolutionContext", Any], Any]]
+        self,
+        fn: Union[ParentFn, AttrWithContextFn, Callable[["ResolutionContext", Any], Any]],
     ):
+        """Resolve this field by invoking the function which will receive the corresponding field value from the parent ResolvableModel."""
         if not isinstance(fn, (ParentFn, AttrWithContextFn)):
             if not callable(fn):
                 check.param_invariant(
@@ -137,33 +286,24 @@ def __init__(
         super().__init__()
 
     @staticmethod
-    def from_model(fn: Callable[["ResolutionContext", Any], Any]):
-        return FieldResolver(ParentFn(fn))
+    def from_annotation():
+        """Resolve this field to the (potentially nested) ResolvedFrom type via its associated ResolvableModel."""
+        return _AutoResolve()
 
     @staticmethod
-    def from_spec(spec: type[ResolvedKwargs], target_type: type):
-        return FieldResolver.from_model(
-            lambda context, model: resolve_model_using_kwargs_cls(
-                model=model,
-                kwargs_cls=spec,
-                context=context,
-                target_type=target_type,
-            )
-        )
+    def from_resolved_kwargs(kwargs_cls: type[ResolvedKwargs]):
+        """Resolve this field to the (potentially nested) target type of the ResolvedKwargs via its associated ResolvableModel."""
+        return _AutoResolve(via=kwargs_cls)
 
     @staticmethod
-    def from_annotation(annotation: Any, field_name: str) -> "FieldResolver":
-        if get_origin(annotation) is Annotated:
-            args = get_args(annotation)
-            resolver = next((arg for arg in args if isinstance(arg, FieldResolver)), None)
-            if resolver:
-                return resolver
-
-            check.failed(f"Could not find resolver on annotation {field_name}")
+    def from_template_injection(via: Optional[type[ResolvedKwargs]] = None):
+        """The complex type at this field must be template injected using an appropriate scope."""
+        return _ExpectedInjection()
 
-        return FieldResolver.from_model(
-            lambda context, model: context.resolve_value(getattr(model, field_name))
-        )
+    @staticmethod
+    def from_model(fn: Callable[["ResolutionContext", Any], Any]):
+        """Resolve this field by invoking the function which will receive the entire parent ResolvableModel."""
+        return Resolver(ParentFn(fn))
 
     def execute(self, context: "ResolutionContext", model: ResolvableModel, field_name: str) -> Any:
         if isinstance(self.fn, ParentFn):
@@ -175,10 +315,10 @@ def execute(self, context: "ResolutionContext", model: ResolvableModel, field_na
             raise ValueError(f"Unsupported DSLFieldResolver type: {self.fn}")
 
 
-TResolvedKwargs = TypeVar("TResolvedKwargs", bound=ResolvedKwargs)
+ResolvedType: TypeAlias = Union[type[ResolvedKwargs], type[ResolvedFrom]]
 
 
-def get_annotation_field_resolvers(kwargs_cls: type[TResolvedKwargs]) -> dict[str, FieldResolver]:
+def get_annotation_field_resolvers(kwargs_cls: ResolvedType) -> dict[str, Resolver]:
     # Collect annotations from all base classes in MRO
     annotations = {}
 
@@ -191,17 +331,14 @@ def get_annotation_field_resolvers(kwargs_cls: type[TResolvedKwargs]) -> dict[st
         annotations.update(base_annotations)
 
     return {
-        field_name: FieldResolver.from_annotation(annotation, field_name)
+        field_name: derive_field_resolver(annotation, field_name)
         for field_name, annotation in annotations.items()
     }
 
 
-TResolvedFrom = TypeVar("TResolvedFrom", bound=ResolvedFrom)
-
-
 def resolve_fields(
     model: ResolvableModel,
-    kwargs_cls: type[TResolvedKwargs],
+    kwargs_cls: Union[type[ResolvedFrom], type[ResolvedKwargs]],
     context: "ResolutionContext",
 ) -> Mapping[str, Any]:
     """Returns a mapping of field names to resolved values for those fields."""
@@ -213,7 +350,7 @@ def resolve_fields(
     }
 
 
-T = TypeVar("T")
+TResolvedFrom = TypeVar("TResolvedFrom", bound=ResolvedFrom)
 
 
 def resolve_model(
@@ -231,8 +368,15 @@ def resolve_model(
 
 def resolve_model_using_kwargs_cls(
     model: ResolvableModel,
-    kwargs_cls: type[TResolvedKwargs],
+    kwargs_cls: Union[type[ResolvedFrom], type[ResolvedKwargs]],
     context: "ResolutionContext",
     target_type: type[T],
 ) -> T:
+    # In the future we will do an explicit validation of alignment, but for now raise a marginally better error.
+    check.inst_param(
+        model,
+        "model",
+        ResolvableModel,
+        "Ensure ResolveFrom field type annotations align with the corresponding ResolvableModel.",
+    )
     return target_type(**resolve_fields(model, kwargs_cls, context))
diff --git a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_field_utilites.py b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_field_utilites.py
index 9eec8d012c968..8a0211782fee7 100644
--- a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_field_utilites.py
+++ b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_field_utilites.py
@@ -2,8 +2,8 @@
 from typing import Annotated
 
 from dagster_components.resolved.model import (
-    FieldResolver,
     ResolvedKwargs,
+    Resolver,
     get_annotation_field_resolvers,
 )
 from pydantic import BaseModel
@@ -51,7 +51,7 @@ def test_override_vanilla() -> None:
     class Base(ResolvedKwargs):
         value: int
 
-    class CustomResolver(FieldResolver): ...
+    class CustomResolver(Resolver): ...
 
     class Derived(Base):
         value: Annotated[str, CustomResolver(lambda context, val: str(val))]
@@ -66,7 +66,7 @@ def test_override_dataclass() -> None:
     class Base(ResolvedKwargs):
         value: int
 
-    class CustomResolver(FieldResolver): ...
+    class CustomResolver(Resolver): ...
 
     class Derived(Base):
         value: Annotated[str, CustomResolver(lambda context, val: str(val))]
@@ -80,7 +80,7 @@ def test_override_pydantic() -> None:
     class Base(BaseModel, ResolvedKwargs):
         value: int
 
-    class CustomResolver(FieldResolver): ...
+    class CustomResolver(Resolver): ...
 
     class Derived(Base):
         value: Annotated[str, CustomResolver(lambda context, val: str(val))]
diff --git a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_from_schema.py b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_from_schema.py
index 1c22e58b37137..b4b91ebd3dfc4 100644
--- a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_from_schema.py
+++ b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_from_schema.py
@@ -1,12 +1,7 @@
 from typing import Annotated
 
 from dagster_components.resolved.context import ResolutionContext
-from dagster_components.resolved.model import (
-    FieldResolver,
-    ResolvableModel,
-    ResolvedFrom,
-    resolve_model,
-)
+from dagster_components.resolved.model import ResolvableModel, ResolvedFrom, Resolver, resolve_model
 
 
 def test_simple_dataclass_resolveable_from_schema():
@@ -17,7 +12,7 @@ class HelloModel(ResolvableModel):
 
     @dataclass
     class Hello(ResolvedFrom[HelloModel]):
-        hello: Annotated[int, FieldResolver.from_model(lambda context, schema: int(schema.hello))]
+        hello: Annotated[int, Resolver.from_model(lambda context, schema: int(schema.hello))]
 
     hello = resolve_model(HelloModel(hello="1"), Hello, ResolutionContext.default())
 
@@ -32,7 +27,7 @@ class HelloModel(ResolvableModel):
     from pydantic import BaseModel
 
     class Hello(BaseModel, ResolvedFrom[HelloModel]):
-        hello: Annotated[int, FieldResolver.from_model(lambda context, schema: int(schema.hello))]
+        hello: Annotated[int, Resolver.from_model(lambda context, schema: int(schema.hello))]
 
     hello = resolve_model(HelloModel(hello="1"), Hello, ResolutionContext.default())
 
@@ -48,7 +43,7 @@ class HelloModel(ResolvableModel):
 
     @dataclass
     class Hello(ResolvedFrom[HelloModel]):
-        hello: Annotated[int, FieldResolver(lambda context, val: int(val))]
+        hello: Annotated[int, Resolver(lambda context, val: int(val))]
 
     hello = resolve_model(HelloModel(hello="1"), Hello, ResolutionContext.default())
 
diff --git a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_model.py b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_model.py
index 2759504d1e6da..498e675d7c5e2 100644
--- a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_model.py
+++ b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_model.py
@@ -2,12 +2,7 @@
 from typing import Annotated, Optional
 
 from dagster_components import ResolutionContext
-from dagster_components.resolved.model import (
-    FieldResolver,
-    ResolvableModel,
-    ResolvedFrom,
-    resolve_model,
-)
+from dagster_components.resolved.model import ResolvableModel, ResolvedFrom, Resolver, resolve_model
 from pydantic import BaseModel
 
 
@@ -16,14 +11,14 @@ def resolve_val1(context: ResolutionContext, schema: "InnerModel") -> int:
 
 
 class InnerObject(BaseModel, ResolvedFrom["InnerModel"]):
-    val1_renamed: Annotated[int, FieldResolver.from_model(resolve_val1)]
+    val1_renamed: Annotated[int, Resolver.from_model(resolve_val1)]
     val2: Optional[str]
 
 
 class TargetObject(BaseModel, ResolvedFrom["TargetModel"]):
     int_val: int
     str_val: str
-    inners: Annotated[Optional[Sequence[InnerObject]], FieldResolver(InnerObject.from_optional_seq)]
+    inners: Annotated[Optional[Sequence[InnerObject]], Resolver.from_annotation()]
 
 
 class InnerModel(ResolvableModel):
diff --git a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_transformer.py b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_transformer.py
index c52eb24e0dce2..cbab344352538 100644
--- a/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_transformer.py
+++ b/python_modules/libraries/dagster-components/dagster_components_tests/resolution_tests/test_resolvable_transformer.py
@@ -1,6 +1,6 @@
 from collections.abc import Sequence
 from dataclasses import dataclass
-from typing import Annotated
+from typing import Annotated, Optional
 
 from dagster._core.definitions.asset_dep import AssetDep
 from dagster._core.definitions.asset_spec import AssetSpec
@@ -10,12 +10,16 @@
 from dagster._core.definitions.definitions_class import Definitions
 from dagster_components import Component, ComponentLoadContext
 from dagster_components.resolved.context import ResolutionContext
-from dagster_components.resolved.core_models import AssetSpecKwargs, AssetSpecModel
+from dagster_components.resolved.core_models import (
+    AssetSpecKwargs,
+    AssetSpecModel,
+    ResolvedAssetSpec,
+)
 from dagster_components.resolved.model import (
-    FieldResolver,
     ResolvableModel,
     ResolvedFrom,
     ResolvedKwargs,
+    Resolver,
     resolve_fields,
     resolve_model_using_kwargs_cls,
 )
@@ -33,8 +37,10 @@ class ExistingBusinessObjectModel(ResolvableModel):
     value: str
 
 
-class ExistingBusinessObjectKwargs(ResolvedKwargs[ExistingBusinessObjectModel]):
-    value: Annotated[int, FieldResolver(lambda context, val: int(val))]
+class ExistingBusinessObjectKwargs(
+    ResolvedKwargs[ExistingBusinessObjectModel, ExistingBusinessObject]
+):
+    value: Annotated[int, Resolver(lambda context, val: int(val))]
 
 
 def test_resolve_fields_on_transform() -> None:
@@ -67,13 +73,16 @@ class ComponentWithExistingBusinessObject(
     ):
         business_object: Annotated[
             ExistingBusinessObject,
-            FieldResolver.from_spec(ExistingBusinessObjectKwargs, ExistingBusinessObject),
+            Resolver.from_resolved_kwargs(ExistingBusinessObjectKwargs),
         ]
 
         def build_defs(self, load_context: ComponentLoadContext) -> Definitions: ...
 
     comp_instance = ComponentWithExistingBusinessObject.load(
-        attributes=ExistingBusinessObjectModel(value="1"), context=ComponentLoadContext.for_test()
+        attributes=ComponentWithExistingBusinessObjectModel(
+            business_object=ExistingBusinessObjectModel(value="1")
+        ),
+        context=ComponentLoadContext.for_test(),
     )
 
     assert isinstance(comp_instance, ComponentWithExistingBusinessObject)
@@ -82,7 +91,7 @@ def build_defs(self, load_context: ComponentLoadContext) -> Definitions: ...
 
 ExistingBusinessObjectField: TypeAlias = Annotated[
     ExistingBusinessObject,
-    FieldResolver.from_spec(ExistingBusinessObjectKwargs, ExistingBusinessObject),
+    Resolver.from_resolved_kwargs(ExistingBusinessObjectKwargs),
 ]
 
 
@@ -110,14 +119,20 @@ class ComponentWithExistingBusinessObjectTwo(
         def build_defs(self, load_context: ComponentLoadContext) -> Definitions: ...
 
     comp_instance_one = ComponentWithExistingBusinessObjectOne.load(
-        attributes=ExistingBusinessObjectModel(value="1"), context=ComponentLoadContext.for_test()
+        attributes=ComponentWithExistingBusinessObjectSchemaTwo(
+            business_object=ExistingBusinessObjectModel(value="1")
+        ),
+        context=ComponentLoadContext.for_test(),
     )
 
     assert isinstance(comp_instance_one, ComponentWithExistingBusinessObjectOne)
     assert comp_instance_one.business_object.value == 1
 
     comp_instance_one = ComponentWithExistingBusinessObjectTwo.load(
-        attributes=ExistingBusinessObjectModel(value="2"), context=ComponentLoadContext.for_test()
+        attributes=ComponentWithExistingBusinessObjectSchemaTwo(
+            business_object=ExistingBusinessObjectModel(value="2")
+        ),
+        context=ComponentLoadContext.for_test(),
     )
 
     assert isinstance(comp_instance_one, ComponentWithExistingBusinessObjectTwo)
@@ -173,23 +188,29 @@ def test_asset_spec():
     assert kitchen_sink_spec.automation_condition.get_label() == "eager"
 
 
-def test_asset_spec_seq() -> None:
+def test_resolved_asset_spec() -> None:
     class SomeObjectModel(ResolvableModel):
+        spec: AssetSpecModel
+        maybe_spec: Optional[AssetSpecModel]
         specs: Sequence[AssetSpecModel]
+        maybe_specs: Optional[Sequence[AssetSpecModel]]
 
     @dataclass
     class SomeObject(ResolvedFrom[SomeObjectModel]):
-        specs: Annotated[
-            Sequence[AssetSpec],
-            FieldResolver(AssetSpecKwargs.resolver_fn(AssetSpec).from_seq),
-        ]
+        spec: ResolvedAssetSpec
+        maybe_spec: Optional[ResolvedAssetSpec]
+        specs: Sequence[ResolvedAssetSpec]
+        maybe_specs: Optional[Sequence[ResolvedAssetSpec]]
 
     some_object = resolve_model_using_kwargs_cls(
         model=SomeObjectModel(
+            spec=AssetSpecModel(key="asset0"),
+            maybe_spec=None,
             specs=[
                 AssetSpecModel(key="asset1"),
                 AssetSpecModel(key="asset2"),
-            ]
+            ],
+            maybe_specs=None,
         ),
         kwargs_cls=SomeObject,
         context=ResolutionContext.default(),


Reviews:
user: login: schrockn
body: This is so great
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T00:50:07Z
user: login: schrockn
body: @alangenfeld  after writing this do you still think we should have the `Resolved` annotation or is it just pointless boilerplate?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T20:00:24Z
user: login: alangenfeld
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T22:06:02Z
user: login: OwenKephart
body: This is a huge step forward. Not fully sold on the Injected terminology, but that is fairly niche and shouldn't block the important changes here imo
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T00:16:56Z
user: login: schrockn
body: I would prefer to start with the more conservative approach but let's chat tmrw
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-11T00:17:32Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:13:08Z
user: login: schrockn
body: With these changes can you delete all the class methods on `ResolveFrom`?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T20:16:02Z
user: login: schrockn
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T20:18:59Z
user: login: schrockn
body: I think overall this is pretty good and represents a reasonable "global maxima" as you described it. I think we need to discuss `from_scope` a bit as that is unclear on inspection to me.

And since he is the one that encountered these issues directly curious re: @benpankow 's feedback
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T20:20:25Z
user: login: schrockn
body: ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/wS38mdcdU6aAeJobBdry/1087c19a-b4ac-4345-aad9-4111f0da211b.png)


state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T22:18:54Z

Review comments:
diff_hunk: @@ -153,16 +218,43 @@ def from_spec(spec: type[ResolvedKwargs], target_type: type):
 
     @staticmethod
     def from_annotation(annotation: Any, field_name: str) -> "FieldResolver":
-        if get_origin(annotation) is Annotated:
-            args = get_args(annotation)
+        if _is_scalar(annotation):
+            return FieldResolver.from_model(
+                lambda context, model: context.resolve_value(getattr(model, field_name))
+            )
+
+        origin = get_origin(annotation)
+        args = get_args(annotation)
+
+        if origin is Annotated:
             resolver = next((arg for arg in args if isinstance(arg, FieldResolver)), None)
             if resolver:
                 return resolver
 
-            check.failed(f"Could not find resolver on annotation {field_name}")
-
-        return FieldResolver.from_model(
-            lambda context, model: context.resolve_value(getattr(model, field_name))
+        if origin in (Union, UnionType) and len(args) == 2:
+            left_t, right_t = args
+            if right_t is type(None):
+                resolved_from_cls = _get_resolved_from_cls(left_t)
+                if resolved_from_cls:
+                    return FieldResolver(resolved_from_cls.from_optional)
+
+                elif get_origin(left_t) in (Sequence, tuple, list):
+                    resolved_from_cls = _get_resolved_from_cls(get_args(left_t)[0])
+                    if resolved_from_cls:
+                        return FieldResolver(resolved_from_cls.from_optional_seq)
+
+        elif origin in (Sequence, tuple, list):
+            resolved_from_cls = _get_resolved_from_cls(args[0])
+
+            if resolved_from_cls:
+                return FieldResolver(resolved_from_cls.from_seq)
+
+        check.failed(
+            f"Could not derive resolver for annotation {field_name}: {annotation}.\n"
+            "Field types are expected to contain:\n"
+            "* basic serializable types such as str, int, float, bool, list, etc\n"
+            "* ResolvedFrom subclasses wrapped with Resolved[]\n"
path: python_modules/libraries/dagster-components/dagster_components/resolved/model.py
user: login: alangenfeld
body: there might be better ways to word it that are not as egregious, but I feel like this would be an annoying error to hit 

"why do i have to mark it `Resolved[]` if it already knows its a `ResolvedFrom` subclass" 

compare with https://github.com/dagster-io/dagster/pull/28366/files#r1988087666
created_at: 2025-03-10T22:06:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,11 +125,69 @@ class AttrWithContextFn:
     callable: Callable[["ResolutionContext", Any], Any]
 
 
+@dataclass(frozen=True)
+class ResolveFromModel:
+    """Annotated marker to specify that the target type should be resolved from its model.
+
+    The via argument allows use for target types that are not directly ResolvableFrom.
+    """
+
+    via: Optional[type[ResolvedKwargs]] = None
+
+
+class ResolveFromInjection:
path: python_modules/libraries/dagster-components/dagster_components/resolved/model.py
user: login: OwenKephart
body: I anticipate that we will want to rename this (and Injected) in the near future. As it stands, this is quite vague. I think the name should potentially include a reference to the fact that there's a string involved in the process somewhere?
created_at: 2025-03-11T00:11:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -153,16 +218,43 @@ def from_spec(spec: type[ResolvedKwargs], target_type: type):
 
     @staticmethod
     def from_annotation(annotation: Any, field_name: str) -> "FieldResolver":
-        if get_origin(annotation) is Annotated:
-            args = get_args(annotation)
+        if _is_scalar(annotation):
+            return FieldResolver.from_model(
+                lambda context, model: context.resolve_value(getattr(model, field_name))
+            )
+
+        origin = get_origin(annotation)
+        args = get_args(annotation)
+
+        if origin is Annotated:
             resolver = next((arg for arg in args if isinstance(arg, FieldResolver)), None)
             if resolver:
                 return resolver
 
-            check.failed(f"Could not find resolver on annotation {field_name}")
-
-        return FieldResolver.from_model(
-            lambda context, model: context.resolve_value(getattr(model, field_name))
+        if origin in (Union, UnionType) and len(args) == 2:
+            left_t, right_t = args
+            if right_t is type(None):
+                resolved_from_cls = _get_resolved_from_cls(left_t)
+                if resolved_from_cls:
+                    return FieldResolver(resolved_from_cls.from_optional)
+
+                elif get_origin(left_t) in (Sequence, tuple, list):
+                    resolved_from_cls = _get_resolved_from_cls(get_args(left_t)[0])
+                    if resolved_from_cls:
+                        return FieldResolver(resolved_from_cls.from_optional_seq)
+
+        elif origin in (Sequence, tuple, list):
+            resolved_from_cls = _get_resolved_from_cls(args[0])
+
+            if resolved_from_cls:
+                return FieldResolver(resolved_from_cls.from_seq)
+
+        check.failed(
+            f"Could not derive resolver for annotation {field_name}: {annotation}.\n"
+            "Field types are expected to contain:\n"
+            "* basic serializable types such as str, int, float, bool, list, etc\n"
+            "* ResolvedFrom subclasses wrapped with Resolved[]\n"
path: python_modules/libraries/dagster-components/dagster_components/resolved/model.py
user: login: OwenKephart
body: I much prefer leaving `Resolved[]` out
created_at: 2025-03-11T00:13:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,26 +48,50 @@ def get_model_type(
         "resolvable_from_type",
     )
     for base in resolved_from_type.__orig_bases__:  # type: ignore
-        # Check if this base originates from ResolvableFromSchema
+        # Check if this base originates from ResolvedFrom
         origin = getattr(base, "__origin__", None)
         if origin is ResolvedFrom:
             type_args = get_args(base)
             if not type_args:
+                raise ValueError("ResolvedFrom base found but no generic type arguments present")
+            return type_args[0]
+
+    raise ValueError("No generic type arguments found in ResolvedFrom subclass")
+
+
+def get_resolved_kwargs_target_type(resolved_kwargs_type: type["ResolvedKwargs"]):
+    """Returns the generic type arguments (TModel, TObject) of a ResolvedKwargs subclass at runtime."""
+    check.param_invariant(
+        issubclass(resolved_kwargs_type, ResolvedKwargs),
+        "resolvable_from_type",
path: python_modules/libraries/dagster-components/dagster_components/resolved/model.py
user: login: graphite-app[bot]
body: The parameter name in the error message doesn't match the actual parameter. The message uses `"resolvable_from_type"` but the parameter is named `resolved_kwargs_type`. For consistency and clarity in error reporting, the error message should be updated to match the parameter name:

```python
check.param_invariant(
    issubclass(resolved_kwargs_type, ResolvedKwargs),
    "resolved_kwargs_type",  # This should match the parameter name
)
```
```suggestion
    check.param_invariant(
        issubclass(resolved_kwargs_type, ResolvedKwargs),
        "resolved_kwargs_type",
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-03-11T20:13:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,33 +280,24 @@ def __init__(
         super().__init__()
 
     @staticmethod
-    def from_model(fn: Callable[["ResolutionContext", Any], Any]):
-        return FieldResolver(ParentFn(fn))
+    def auto():
+        """Resolve this field to the (potentially nested) ResolvedFrom type using via its associated ResolvableModel."""
+        return AutoResolve()
 
     @staticmethod
-    def from_spec(spec: type[ResolvedKwargs], target_type: type):
-        return FieldResolver.from_model(
-            lambda context, model: resolve_model_using_kwargs_cls(
-                model=model,
-                kwargs_cls=spec,
-                context=context,
-                target_type=target_type,
-            )
-        )
+    def resolved_kwargs(kwargs_cls: type[ResolvedKwargs]):
+        """Resolve this field to the (potentially nested) target type of the ResolvedKwargs via its associated ResolvableModel."""
+        return AutoResolve(via=kwargs_cls)
 
     @staticmethod
-    def from_annotation(annotation: Any, field_name: str) -> "FieldResolver":
-        if get_origin(annotation) is Annotated:
-            args = get_args(annotation)
-            resolver = next((arg for arg in args if isinstance(arg, FieldResolver)), None)
-            if resolver:
-                return resolver
+    def from_scope(via: Optional[type[ResolvedKwargs]] = None):
path: python_modules/libraries/dagster-components/dagster_components/resolved/model.py
user: login: schrockn
body: this name doesn't feel quite right. `from_template_injection`?
created_at: 2025-03-11T20:18:58Z
author_association: MEMBER

PR comments:
user: login: alangenfeld
created_at: 2025-03-07T22:02:38Z
body: * **#28366** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28366?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#28330** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28330?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28330?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `master`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-07T22:59:31Z
body: Deploy preview for _dagster-docs_ ready!

Preview available at https://dagster-docs-76nlzoose-elementl.vercel.app
https://al-03-07-rfc-resolved-nested-model-resolution-update.archive.dagster-docs.io

Direct link to changed pages:
author_association: CONTRIBUTOR
user: login: alangenfeld
created_at: 2025-03-10T20:05:35Z
body: let me stack a PR where its removed to see how it feels 
author_association: CONTRIBUTOR
user: login: alangenfeld
created_at: 2025-03-10T20:16:52Z
body: see https://github.com/dagster-io/dagster/pull/28366/files
author_association: CONTRIBUTOR
user: login: schrockn
created_at: 2025-03-11T00:17:02Z
body: Looking at the alternatives I think I would prefer either _less_ boilerplate or _more_ boilerplate than the current PR.

Seems like there are three approaches to me:

1)   op: Optional[Annotated[OpSpec, FieldResolver.auto(OpSpec)]] = None
2)  op: Optional[Resolved[OpSpec]] = None
3)  op: Optional[OpSpec] = None

I would prefer something like 1) at this point to be honest. At least there are only _two_ ways of doing things in that way (one being a naked model or the other being an `Annotated` one with a `FieldResolver` annotation. Having _three_ ways just kind of makes my brain melt looking at the code.

It would be pretty straightforward to introduce a `Resolved` annotation incrementally that handles the `FieldResolver.auto(OpSpec)` boilerplate, but we could never really claw `Resolved` back

author_association: MEMBER
user: login: alangenfeld
created_at: 2025-03-11T20:41:48Z
body: > can you delete all the class methods on ResolveFrom?

done - let me know if this looks like what you had in mind

> `from_scope`

yea i am open to changing any of the new names, `auto`, `from_scope`, `resolved_kwargs` 
author_association: CONTRIBUTOR