The dtype member is a property, not a field, so when you call composite_space.dtype, it creates and returns a nested dtype on the fly. Because composite_space.dtype is created on-demand, it is never out of sync with composite_space. 

"It seems more elegant to me to have one tree of Spaces where only the leaves have dtypes."

Unless I've misunderstood your comment, this describes the current state of the code. Only leaves have dtype members. CompositeSpace.dtype calls a method that gathers them all up in a nested tuple.

"More philosophically, we might one day want to have Theano Variables / Pylearn2 spaces that represent non-numeric entities, such as strings. I guess their dtype could be 'char'."

I think there's room for spaces where dtype is something other than numpy.dtype would accept (and therefore not too useful). NullSpace already does this by returning "NullSpace's dtype" as its dtype string. Perhaps ("%s's dtype" % type(self)) should just be the default implementation of the dtype getter, in Space.