- Problem
- Solution
- Unified ID Scalar
- First-Class Interfaces
- Fragment Support
- Interface-Implements-Interface
- SDK Codegen
- Status
- Global Object Identification
- Loose Ends
-
Interfaces were objects. DagQL had no concept of GraphQL interfaces. Module-defined interfaces were installed as
Class[*InterfaceAnnotatedValue]withKind: ast.Object— notast.Interface. -
asFooconversion fields. The only way to "upcast" from a concrete object to an interface was via syntheticasFoofields bolted onto every implementing object byModDeps.lazilyLoadSchema. -
Per-type ID scalars. Every object had its own
FooIDscalar (ContainerID,DirectoryID, etc.) which cluttered the schema and made interface-typed arguments difficult — SDKs couldn't accept "any ID that implements this interface" withoutDynamicIDwrapping. -
No fragment support.
parseASTSelectionsdidn't handle*ast.InlineFragmentand didn't check type conditions on fragment spreads. -
InterfaceAnnotatedValue+DynamicIDwrapping. Interface values were wrapped inInterfaceAnnotatedValue(implementingdagql.InterfaceValue), andtoSelectablehad a special unwrapping path. Fragile complexity. -
No
implementsin the schema. Object definitions never declared which interfaces they implement, andpossibleTypeswas empty. -
Subtype checking lived in core.
ObjectTypeDef.IsSubtypeOfand friends implemented covariant/contravariant type checking, but dagql knew nothing about any of it.
Two complementary changes, developed together on this branch:
-
Unified ID scalar: Replace all per-type
FooIDscalars with a singleIDscalar. Type information is conveyed via@expectedType(name: "Foo")directives on arguments and field definitions. -
First-class interfaces: DagQL natively represents
ast.Interfacetypes, tracks which objects implement them, populatespossibleTypes, and supports inline fragments and fragment spreads with type condition checking.
Together these eliminate InterfaceAnnotatedValue, DynamicID for interfaces,
the dagql.InterfaceValue unwrap path, wrapIface, and the synthetic asFoo
schema fields.
# Before
type Container {
id: ContainerID!
withDirectory(path: String!, directory: DirectoryID!): Container!
}
# After
type Container {
id: ID! @expectedType(name: "Container")
withDirectory(path: String!, directory: ID! @expectedType(name: "Directory")): Container!
}dagql.AnyID is the single scalar type for all IDs. It wraps a *call.ID
and handles encode/decode. Every object's id field uses AnyID with an
@expectedType directive.
Installed on the server as a schema directive:
directive @expectedType(name: String!) on ARGUMENT_DEFINITION | FIELD_DEFINITIONApplied in two places:
-
Arguments (
dagql/objects.goInputSpecsForType,core/typedef.goFunctionFieldSpec,core/interface.go): When an argument's input type isID[T]or a module object/interface type, the directive is added with the resolved type name. List wrappers are walked through to find the element type. -
Field definitions (
dagql/objects.go): When a field's return type isID(e.g.sync,id), the directive is added to the field definition itself.
SDKs read @expectedType from introspection to generate properly typed
method signatures instead of raw ID/string parameters.
A new type alongside Class:
type Interface struct {
name string
description string
fields map[string][]*InterfaceFieldSpec // view-keyed, same as Class
fieldsL *sync.Mutex
implementors map[string]struct{} // type names
interfaces map[string]*Interface // interface-extends-interface
directives []*ast.Directive
}Key methods:
| Method | Purpose |
|---|---|
AddField(spec) |
Add a field spec to the interface |
FieldSpecs(view) |
Return visible fields for a view |
ParseField(ctx, view, astField, vars) |
Parse an AST field selection against interface specs |
Definition(view) |
Generate ast.Definition with Kind: ast.Interface |
Satisfies(obj, view, ...checker) |
Structural conformance check with optional covariant return support |
SatisfiedByInterface(other, view) |
Interface-to-interface conformance |
ImplementInterface(other) |
Declare this interface extends another |
// dagql/server.go
type Server struct {
interfaces map[string]*Interface // alongside objects, scalars, etc.
}
func (s *Server) InstallInterface(iface *Interface, directives ...*ast.Directive) *Interface
func (s *Server) InterfaceType(name string) (*Interface, bool)Class.Implements(iface) and Class.ImplementInterfaceUnchecked(iface) record
that an object implements an interface. At schema generation time:
- The object's
ast.DefinitiongetsInterfaces: []string{"Foo", "Bar"} schema.PossibleTypes["Foo"]includes this object's definition
Every class with an ID automatically implements the built-in Node
interface (id: ID!), registered in Server.InstallObject:
if _, hasID := class.IDType(); hasID {
if nodeIface, ok := s.interfaces["Node"]; ok {
if nodeIface.Satisfies(class, "") {
impl.ImplementInterface(nodeIface)
}
}
}Server.SchemaForView emits both objects and interfaces:
// Objects → ast.Object, with def.Interfaces populated
sortutil.RangeSorted(s.objects, func(_ string, t ObjectType) {
def := definition(ast.Object, t, view)
for _, ifaceName := range def.Interfaces {
schema.AddPossibleType(ifaceName, def)
}
schema.AddTypes(def)
})
// Interfaces → ast.Interface
sortutil.RangeSorted(s.interfaces, func(_ string, iface *Interface) {
def := iface.Definition(view)
schema.AddTypes(def)
for _, ifaceName := range def.Interfaces {
schema.AddPossibleType(ifaceName, def)
}
})InterfaceType.Install (in core/interface.go) creates a dagql.Interface
with field specs derived from the module's InterfaceTypeDef, then calls
dag.InstallInterface().
Object-to-interface relationships are registered in registerInterfaceImpls
(core/schema_build.go), which uses IsSubtypeOf for semantic validation
and dagqlIface.Satisfies with a covariant return type checker for structural
conformance.
parseASTSelections handles all three selection types:
case *ast.Field:
// Standard field resolution against object or interface parser
case *ast.FragmentSpread:
// Check type condition via typeConditionMatches()
if s.typeConditionMatches(fragment.TypeCondition, self.Name()) { ... }
case *ast.InlineFragment:
// Check type condition; empty condition always matches
if x.TypeCondition == "" || s.typeConditionMatches(x.TypeCondition, self.Name()) { ... }typeConditionMatches supports exact type name match plus interface
membership (checks iface.Implementors()).
Both objects and interfaces can serve as the fieldParser in
parseASTSelections — the server checks s.objects[name] then falls back
to s.interfaces[name].
The __typename meta-field is handled as a built-in in parseASTSelections
and resolved in Server.Resolve by returning the concrete object's type name.
Interfaces can implement other interfaces via duck typing.
registerInterfaceToInterfaceImpls (core/schema_build.go) compares every
pair of module-defined interfaces: if interface A has all fields required by
interface B with compatible types, A declares ImplementInterface(B).
Circular references are prevented: if B already implements A, A won't also declare it implements B.
All SDKs have been updated. The @expectedType directive is the key
mechanism that lets SDKs map the unified ID scalar back to typed parameters.
- Interfaces → Go
interfacetype + unexportedFooClientquery-builder struct AsFoo()adapters → Generated on implementing objects as client-side type conversions (no schema field). Needed because Go lacks covariant return types on interface methods.Concrete(ctx) (Node, error)→ On interface clients, queries__typename+idand type-switches onpossibleTypesto load the concrete type.Nodeinterface → Base interface for all typed objects, returned byConcrete().- Unified
ID→ Singletype ID string. No per-typeContainerIDaliases.XXX_GraphQLIDType()returns"ID"everywhere. Ref[T]andLoad[T]→ Generic helpers for loading objects by ID vianode(id:).Refreturns a lazy reference (no network call);Loadverifies the object exists and matches the expected type.
- Interfaces →
@runtime_checkableProtocol classes as_foo()adapters → ReturnsSelf(the object already structurally satisfies the protocol via duck typing)- Unified
ID→strtype used in module runtime
- Interfaces → TypeScript
interface+_FooClientquery-builder class - Structural typing → Concrete classes satisfy interfaces without adapters
- Unified
ID→IDtype alias
- Interfaces → PHP
interface+FooClientimplementing class - Unified
ID→ SingleIdscalar, per-typeFooIdclasses removed loadObjectFromId()→ Generic helper onAbstractClientreplaces dynamicload*FromId()calls
- Interfaces → Java
interface+FooClient implements Foo - Unified
ID→ UpdatedIDAbleSerializerfor single ID type loadObjectFromID(Class<T>, ID)→ Generic helper on generated Client class
- Unified
ID→ Updated scalar templates, regenerated bindings - Interfaces → Not yet generating native Rust trait types (regen only)
- Inline fragments →
inline_fragment/2added to QueryBuilder - Unified
ID→@expectedTypedirective support on args and fields, unifiedIDscalar,INTERFACEkind handling node(id:)+ inline fragment → Replaces allloadFooFromIDcallsNodeinterface → Generated as a type- 52 per-type
FooIDscalar files removed
- Interfaces → C# interface generation
- Unified
ID→Idclass with@expectedTypesupport, inline fragments
Done:
dagql.Interfacetype with field specs,ParseField,Satisfies,SatisfiedByInterfaceServer.InstallInterface,InterfaceType, schema generation withast.InterfaceandpossibleTypesClass.Implements/ImplementInterfaceUncheckedwith covariant return type checking viaImplementsChecker- Auto-
Nodeinterface for all ID-able classes InterfaceType.Installusingdagql.Interface(notClass[*InterfaceAnnotatedValue])registerInterfaceImplsandregisterInterfaceToInterfaceImplsincore/schema_build.go- Inline fragment and fragment spread support with
typeConditionMatches __typenamemeta-field supportAnyIDscalar with@expectedTypedirective replacing all per-type ID scalarsasFooschema fields removedInterfaceAnnotatedValue,wrapIface,dagql.InterfaceValueremoved- SDK codegen updated: Go, Python, TypeScript, PHP, Java, Rust, Elixir, .NET
- All generated SDK files regenerated
- Integration tests updated for
dagger.IDandID!query strings - Global Object Identification:
node(id: ID!): Nodefield on root query - All per-type
loadFooFromIDfields removed from schema Objectinterface renamed toNode- CLI
@expectedType-based flag resolution for unifiedIDargs (scalar and list element types)
Every object type gets a loadFooFromID(id: ID!): Foo! field on the root
query — 59 of them in the current core schema, plus more for every module
type. These are all identical: decode the ID, validate the type name, call
Server.Load. They clutter the schema, bloat SDK codegen (each one
generates a public LoadFooFromID method), and deviate from the standard
GraphQL pattern for object refetching.
With unified IDs and first-class interfaces already landed, there is no
longer any reason for per-type load fields. The ID itself carries the
concrete type, and every object already implements an interface with
id: ID!.
Adopt GraphQL Global Object Identification.
Replace all loadFooFromID fields with a single node field:
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
}Every object with an ID implements Node. Clients use inline fragments
to access type-specific fields:
{
node(id: "...") {
id
... on Container {
platform
rootfs { entries }
}
}
}| Before | After |
|---|---|
interface Object { id: ID! } |
interface Node { id: ID! } |
loadContainerFromID(id: ID!): Container! |
removed |
loadDirectoryFromID(id: ID!): Directory! |
removed |
| ... ×59 | node(id: ID!): Node |
The return type is nullable Node — if the ID is invalid or refers to
a type not in the current schema view, the field returns null (standard
GraphQL convention for node).
For module-defined interfaces, the per-interface loadFooFromID is also
removed. The node field handles them the same way: the ID encodes the
concrete type, Server.Load resolves it, and the client narrows via
fragment spreads.
Node interface and node field:
The Node interface is auto-implemented by every class with an ID,
installed in Server.InstallObject. The node(id: ID!): Node field
is installed on the root query:
// dagql/server.go — installed once, before any objects
nodeIface := NewInterface("Node", "An object with a globally unique ID.")
nodeIface.AddField(InterfaceFieldSpec{
FieldSpec: FieldSpec{
Name: "id",
Type: AnyID{},
},
})
s.InstallInterface(nodeIface)s.Root().ObjectType().Extend(
FieldSpec{
Name: "node",
Description: "Load any object by its ID.",
Type: nodeIface.Typed(), // returns Node interface
Args: NewInputSpecs(
InputSpec{Name: "id", Type: AnyID{}},
),
IsNullable: true,
},
func(ctx context.Context, _ AnyResult, args map[string]Input) (AnyResult, error) {
idable, ok := args["id"].(IDable)
if !ok {
return nil, fmt.Errorf("expected IDable, got %T", args["id"])
}
return s.Load(ctx, idable.ID())
},
)All per-type loadFooFromID fields have been removed from
Server.InstallObject and module interface installation.
All SDKs use node(id:) with inline fragments internally. The Go SDK
provides generic helpers:
// selectNode is the canonical helper used by all generated code.
func selectNode(q *Selection, id any, typeName string) *Selection {
return q.Select("node").Arg("id", id).InlineFragment(typeName)
}
// Ref returns a lazy reference to a node by its ID (no network call).
func Ref[T Loadable[T]](c *Client, id ID) T {
var zero T
return zero.WithGraphQLQuery(selectNode(c.query, id, zero.XXX_GraphQLType()))
}
// Load verifies the node exists and matches the expected type.
func Load[T Loadable[T]](ctx context.Context, c *Client, id ID) (T, error) { ... }Other SDKs follow the same pattern with their own selectNode /
node(id:) + inline fragment helpers (Python, TypeScript, PHP, Java,
Elixir, .NET).
- Schema size: Root query drops from ~60 load fields to 1.
- Spec compliance: Standard
node+Nodeinterface means any GraphQL client library with Relay-style caching works out of the box. - Normalized caching: Clients that implement normalized caches (Apollo, Relay, urql) can automatically refetch and cache objects by ID without per-type configuration.
- Simpler codegen: SDKs generate one
nodemethod. Typed helpers likeRef[T]andLoad[T]are client-side sugar with no schema coupling.
-
Core interface types —
core/schema_build.go:69has// TODO support core interfaces types. Only module-defined interfaces go through theTypeDefs → InterfaceType → registerInterfaceImplspath. Core-defined interfaces (e.g.Node) are installed directly viaServer.InstallInterfacebut don't participate in the module-level conformance registration. If core ever defines richer interfaces this will need work. -
Evaluator module — Still uses its own
EvalWorkspaceEvalIDtype (per-type scalar) in generated code. Needs revisiting once the unified ID story is settled for module-defined ID-like types. -
Rust SDK — Regenerated for unified
IDscalar, but not yet generating native Rust trait types for GraphQL interfaces. Just regen'd the bindings. -
TODOincore/interface.go—ConvertFromSDKResulthas aslog.Warnfor nil values and a"this seems expensive"note onloadImplbeing called on every conversion. Worth profiling. -
TODOincore/object.go— Same nil-value warning pattern as interfaces:ConvertFromSDKResult: got nil valuewith a suggestion thatNullablewrapping should handle this instead. -
Argument contravariance —
Interface.Satisfiesintentionally omits argument contravariance checking. Core'sIsSubtypeOfhandles the semantic rules, but dagql's structural check is return-type-only. -
DynamicIDremnant —dagql/types.gostill hasNewDynamicID(one reference). This was the old mechanism for wrapping interface values in IDs. May be dead code now thatInterfaceAnnotatedValueis gone. -
Integration test cleanup — Some integration tests still use removed APIs (
dag.Load*FromID) in inline Go module source strings. These are being fixed as they're found; grep fordag\.Load.*FromIDincore/integration/to find remaining instances.
Changelog
loadFooFromIDfields with a singlenode(id: ID!): Noderoot field per the GraphQL specObjectinterface toNodeto match specnodemethod, typedLoadFooFromIDhelpers become client-side sugar callingnodeloadFooFromIDfor one release cycle