1. 42

This post is about 90% inspired by What to Know Before Debating Type Systems and 10% inspired by Type Systems: The Good, Bad, and Ugly. In the former, Smith makes the point that static typing and dynamic typing use fundamentally different definitions of what a ‘type’ is. Static typing is a property of the code, while dynamic typing is about runtime behavior. That’s gotten me interested in what you can really do with dynamic typing. All of the arguments online assume it’s just “no static” so it’s easier to prototype. “Here’s something annoying you don’t have to deal with.” I want to hear more about the kinds of tricks that would be hard or impossible to replicate in static type systems. Note it doesn’t have to be a good engineering or production quality or anything. I just want to know about interesting things.

Here’s my contribution: Python has Abstract Base Classes. In addition to the usual ways those are defined, Python ABCs have a special method called __subclasshook__. This lets you define at runtime what classes are considered subclasses of the ABC. For example:

from abc import ABC

class CoordinateAbc(ABC):
  @classmethod
  def __subclasshook__(cls, other):
     return all((hasattr(other, method) for method in ['__add__', '__sub__', '__abs__']))

Then any object you could reasonably use as a coordinate in a coordinate system is considered an instance of CoordinateAbc. You can also define subclasshooks like “classes that have at least one method with lru_cache” or “classes that are used as arguments when initializing some other class”.

    1. 19

      In my thesis – using Common Lisp – I automatically defined slots for classes and methods (in simple cases) based on how the code was written.

      Basically, I setup a handler around function calls such that if the system signalled an error of type “undefined function” (*) it examined the call and, if it matched some predefined patterns, it would add the slot to the class, define an accessor method (or something similar), then restart the computation at that point.

      After that finished, you could go and specialize the code that was produced so that the error handling wasn’t generated and the restarts would become unnecessary. I even got it to the point where it would derive subclass relationships, and it worked on simple cases of multiple inheritence.

      (*) Not exactly that since I can’t remember the precise details, but that was the gist of it.

      1. 10

        Addendum: I realize I misread the question. I thought it said “What’s the most interesting thing you’ve done with dynamic typing?”, not “thing you’ve seen done”. I’m not so arrogant as to assume what I’ve done is the most interesting thing I’ve seen.

      2. 2

        It sounds interesting, from skimming your thesis, the gist of it seems that you change the concrete data structure on the fly when a new method would require it?

        https://cs.uwaterloo.ca/~smwatt/home/students/theses/GWozniak2008-phd.pdf

        1. 3

          Yeah, that’s the gist of it. There were two main components: using a kind of online profiling to specialize data structures and the aforementioned class creation. Both fell under the theme of online, experimental development that allows a program to run when “underspecified”. I was exploring different ways to keep the code relatively high-level looking, but having the compiler use earlier profiling information to make specializations. I didn’t get it totally implemented, sadly. Still, I’d like to revisit it some day, espeically after working on real code bases for the past 9 years.

          Don’t make me go into details. I’ve long since forgotten them. ;)

      3. 2

        An error like “undefined function” does not exist in statically typed languages. If a function is not defined, the program is invalid and cannot be compiled or executed. While I like your story, it is not a story about “tricks that would be hard or impossible to replicate in static type systems”, which op asked for.

        1. 7

          I think I am missing something here. To me this reads as “it’s not impossible to replicate it because it’s impossible to replicate”.

        2. 2

          I thought I did satisfy the question of the OP:

          I want to hear more about the kinds of tricks that would be hard or impossible to replicate in static type systems.

          Since, as you say, that error doesn’t exist, what I described is, thus, impossible in a static type system by the simple fact that it’s not applicable.

          Technically, what I did may be possible to some extent in a static type system, but it would have to be done at the compiler level, in an early “pre-processing” kind of pass.

          1. 1

            I think they were saying that statically typed languages preclude that class of problems, i.e., you are using the type system (or apparent lack thereof) to solve a problem created by the type system itself.

            1. 1

              Yeah, that’s true. And as I mentioned, it can probably be done in the compiler in some way, but that is, arguably, outside the type system.

              I should point out that the work I did wasn’t meant as a runtime you would put into production. It was meant more as an “early stage development” kind of tool.

    2. 15

      Something like Smalltalk is enabled by dynamic typing. It would be hard (impossible?) to build a statically typed self-modifying environment because you cannot know a priori all the types of objects that will be created.

      Although this may be more to do with the idea of message passing than types. You could have a static typechecker for Smalltalk, but the only interface you can expect of objects outside your control is that they can receive messages. For anything else, you have to wait until runtime to ask them.

      Which brings me to a side thought… message passing-based OOP is basically the weakest of typing. This is ironic considering how modern OOP is often associated with cumbersome static typing.

      1. 6

        It would be hard (impossible?) to build a statically typed self-modifying environment because you cannot know a priori all the types of objects that will be created.

        While I agree for the most part, the various versions of the Oberon system are self-modifying with an extensible static type system. They’re elegant in their simplicity.

      2. 4

        I don’t agree that this needs to be the case. There are many ways it could be implemented - static typing doesn’t have to mean destruction of the image if there’s a type failure. It could simply tell you that a specific method invocation isn’t expected to work, yet still generate the bytecode that will fail at run-time (and wouldn’t JIT, one would imagine)

      3. 4

        Something like Smalltalk is enabled by dynamic typing.

        No. See StrongTalk, which is a statically typed SmallTalk.

    3. 10

      Macros are hard to do well in a statically typed language. Some important Clojure macros are inherently non-typed, e.g. the threading macros -> and ->>. You can replicate this well enough with other operator chains, but there’s no type to the concept of ->, which (conceptually) takes an a0 and arguments a0 -> a1, a1 -> a2, … aN-1 -> aN and produces an aN. I suppose you could say that it has an infinite “type schema” (like axiom schemas in math) but once you’re talking about type schemas, you’ve walked away from static typing.

      Live code patching is likewise hard to do if you’re faithful to a statically typed model.

      You can model any dynamically typed system in a statically typed world: just use tagged union types. The issue is that the programmer still has to know a priori what types he wants to model. Quantifying over all types in the world (or, say, a subset with a specific structure) is something that dynamically typed languages do with ease.

      Most things you would want to do in a dynlang, there is a way to do in a language like Haskell. It might be hard to understand, though, if you’re not used to type systems. For example, it takes quite a bit of depth in understanding to know how lens works, and lenses are largely to do something that’s easy (player[i].stats.hp += 20) in dynamic, OOP languages.

      Here’s another one that’s hard to model, in full faith, in a statically typed language: dataframes (as in R or Pandas). If you model a dataframe as a [[Double]] (i.e. a matrix) then you can do that in either type regime. But, let’s say that you want statically typed dataframes. Seems like a reasonable thing to ask for. You might have a dataframe of {Name: String, EyeColor: Color, Age: Double, Height: Double} where Color is a categorical variable. You need row types, which aren’t such a big thing to ask for… but, here’s where it gets messy. Let’s say that you have a 500-column dataframe. That’s not especially unusual. Let’s also say that you have data-dependent data-cleaning passes that affect the type of each row, like “Remove any column that’s 90% missing”, and “Remove columns if any are linearly dependent”. Now, you have 2^500 different possible output types to your data cleaning. (Dependent types, here we come!) Dynamic languages handle this with ease. Statically typed languages right now don’t, unless you use types that are less descriptive than what’s possible (i.e. a matrix of Double instead of a [UserSpecifiedRowType])… but then you’re not using the full power of static typing and you have to include a bunch of extra checks (i.e. does this double correspond to a meaningful category index) to avoid those errors.

      1. 8

        You can replicate this well enough with other operator chains, but there’s no type to the concept of ->, which (conceptually) takes an a0 and arguments a0 -> a1, a1 -> a2, … aN-1 -> aN and produces an aN. I suppose you could say that it has an infinite “type schema” (like axiom schemas in math) but once you’re talking about type schemas, you’ve walked away from static typing.

        Statically-typed type-aligned queues exist in Haskell, Scala (I’ve implemented one) and others. One needs existentials but one can always encode those via the continuation trick (forSome a . a becomes forAll b . (forAll a . a -> b) -> b, so I see no reason one couldn’t implement the same thing in e.g. Java.

      2. 6

        The issue is that the programmer still has to know a priori what types he wants to model.

        Not true. Existential types give you universal/open sums. OCaml’s polymorphic variants do, too. In each case you just have to manage the “tags” explicitly. Which sucks.

        Also, w.r.t. your data frame example: I think that you’re giving the dynamic language more credit than it deserves. The operation: “remove all linearly dependent rows” has a type like DataFrame MyKnownAttributes -> DataFrame GenericAttributes and it has that same conceptual type in a dynamic language as well: in each case you must check the result to understand the shape of it.


        Macros are a good example, though. Fundamentally, typing occurs “after” syntax. It ought to be blind to it.

        1. 3

          Not true. Existential types give you universal/open sums. OCaml’s polymorphic variants do, too. In each case you just have to manage the “tags” explicitly. Which sucks.

          Fair point. I forgot about existential types entirely when I wrote that comment. You’re absolutely right.

          You’re right that the dynamic data frame case requires a lot of runtime checking, because if you do data transforms like the kind I talked about, there’s not a lot than you can rely on until you inspect the output.

        2. 2

          Last statement inspired me to submit a work on macros for statically-typed languages here:

          https://lobste.rs/s/bxo9mp/macros_as_multi_stage_computations_type

          The limit of that work from 2001 was that it was for generative macros and something about equivalence. That should be good for many things I’ve seen them used for, though.

      3. 2

        I see no problem with the threading macros. Here is an implementation in D which does it recursively:

        auto threadFuncs(T, F...)(T t, F f)
        {
            static if (f.length == 0)  {
                return t;
            } else {
                return threadFuncs(f[0](t), f[1..$]);
            }
        }
        

        Here is a full program which can be run online to verify it does the correct thing.

        With respect to tagged union types, D has Variant, which does not need to know all possible types in advance. Ok, it has restrictions like “no types with shared qualifier”. Also, it must know the size of all possible types in advance, but that is also the case for dynamically typed languages (just use references).

    4. 8

      To be honest, your CoordinateAbc technique seems like really complex and error-prone structural typing…

      1. 1

        It’s a pretty simple example, admittedly, but you can make more complex types that’d be hard to do structurally. For example, classes that don’t have classmethods, classes that have at least one method that doesn’t take a parameter, classes with an odd number of letters in the name, etc. Not something you want to do in production, probably, but still interesting.

    5. 7

      The Twisted Python networking framework uses a “Protocol” object to serialise and deserialise messages from a byte-stream, so protocols are not tied to any particular transport.

      Some protocols (SOCKS being a good example) start with a request/authorization phase, but if the authorization succeeds, the protocol switches to a transparent byte-pipe. One implementation strategy might be something like:

      def dataReceived(self, data):
          if self.auth_succeeded == True:
              self.pass_through(data)
          else:
              self.parse_request(data)
      

      Alternatively, somebody who’s read Design Patterns might create a RequestState and a PipeState, and have the Protocol object replace one with the other when authentication succeeds.

      But in Python… each class instance has a magic __class__ property that tells you what class the instance is an instance of… and that property is writable. I have seen (not written, but seen) production code for a SOCKS-like protocol that, upon successful authorisation did something like:

      self.__class__ = PassthroughProtocol
      

      …to suddenly and instantly switch which class the instance belongs to. The original author had the grace to leave a big scary “here be dragons” comment above the line in question, and for the application in question it probably was the right implementation choice, but I’ve always been impressed at the audacity of that trick.

    6. [Comment removed by author]

    7. 8

      erlang uses it to allow runtime update of code with zero downtime. OTP has hooks for transforming the old data formats to new data formats on live systems.

      1. 7

        Why is this enabled by dynamic typing? In my view it might be possible to achieve the same with static types. For example by restricting code updates to the same type signature as the code they are replacing (for erlang that would be the types of the exported functions) and allowing those hooks to transform one incoming type to a different outgoing type if the types needed to be changed.

        1. 2

          I think the type signatures might go a bit crazy when you have so many generic functions. If the vm type checked upgrade code against old and new types it could be quite neat.

          1. 6

            There are a number of approaches to deal with strong static types with hot-swap upgrades. Cloud Haskell (basically an effort to copy Erlang’s actor model features to Haskell) uses Haskell’s Typeable library. It’s a bit messy, but it does allow for type-safe hot replacement of distributed system components without having to e.g. transmit values as JSON or something.

            1. 3

              Erlang works such that you could make a reference to an anonymous function in a module at version 1, send it over to a process that uses version 2 of the module to wrap it in another one, and pass it to a process that runs version 1 again. For that period of time, until version 1 is unloaded, this is a valid operation that should be possible to execute and unwrap.

      2. 6

        Haskell, quintessentially strongly typed, has the “plugins” library which allows runtime hot-loading. I think this is sort of orthogonal to how the language is typed.

        1. 4

          Erlang is strongly typed too. I think you meant static :)

        2. 1
      3. 3

        Java can do it. C can do it (and thus everything using dll/so/dylib libraries). C# can do it. So all mainstream statically typed languages are capable of a runtime update of code with zero downtime. They are not designed to do it, so it does not work as nice as in Erlang, but static typing is not part of the problem.

        1. 1

          That depends whether you define “problem” as “a property making it impossible to do something” or “a property making it a pain in the ass to do something”. IMO static type systems don’t make it impossible to hot-reload code, but they sure as heck make it hard, which is why statically-typed languages tend to only get that feature implemented once they’re really mature.

    8. 4

      The live debugging and editing capabilities of the environments below. I want that in any computer I have. That capability appears to be both a “relic of the past” and also better than anything we have today if we’re talking whole stack.

      http://www.symbolics-dks.com/Genera-why-1.htm

      https://www.quora.com/What-was-it-like-to-be-at-Xerox-PARC-when-Steve-Jobs-visited

    9. 4

      I think phuby qualifies?

    10. 3

      I want to hear more about the kinds of tricks that would be hard or impossible to replicate in static type systems

      There is nothing “impossible to replicate” and what is “hard to replicate” is usually not hard in other statically typed languages and just as hard in other dynamically typed languages. For the example with subclasshook, how would you do that in Javascript? And the statically typed C++ can probably replicate your CoordinateAbc via template instantiation. I use “probably” because I’m not quite sure what you are trying to achieve in this example.

      For further inspiration, I can recommend Rob Harper:

      To borrow an apt description from Dana Scott, the so-called untyped (that is “dynamically typed”) languages are, in fact, unityped. Rather than have a variety of types from which to choose, there is but one!

      And this is precisely what is wrong with dynamically typed languages: rather than affording the freedom to ignore types, they instead impose the bondage of restricting attention to a single type!

      1. 1

        There is nothing “impossible to replicate” and what is “hard to replicate” is usually not hard in other statically typed languages and just as hard in other dynamically typed languages.

        That’s quitter talk.

        Okay, more seriously, what I’m trying to do is find examples of what makes dynamic types interesting. Saying the equivalent of “nothing” is like saying “there’s nothing functional programming gives you that OOP doesn’t”: regardless of whether it’s true or not, it doesn’t give us anything to work with. It doesn’t give us anything worth investigating.

        Also, I’d be absolutely shocked if dynamic typing provided nothing aside from “unityping”. Especially given that (anecdotally) there’s a huge amount of research into making better static type systems and there’s almost no research in dynamic typing. I don’t want to write off something we haven’t even explored.

      2. 1

        The example is essentially trying to write a typeclass instance like

        instance (HasLength a, HasAdd a) => CoordinateAbc a
        
        1. 1

          Correct me if I’m wrong, but don’t you have to explicitly declare the typeclass on a? With __subclasshook__ isinstance(a, Abc) even if we don’t say that a inherits from Abc at definition, or if a is declared without an add method but it’s dynamically added at runtime.

    11. 2

      I don’t know that it’s all that interesting but the way parameter rewriting is done in dpdb has always pleased me.

      dpdb is a simple module that abstracts away the different parameter quoting styles allowed by Python’s DB-API (as well as doing a few other things). The rewriting is done by using Python’s string interpolation, but passing dictionary-like objects from which the keys are looked up. As a side effect of lookup, the final parameter substitution is built up.

    12. 2

      This isn’t necessarily the most interesting use, but one I’m engaged with at the moment:

      We’ve got a legacy codebase that uses a data access library (let’s call it Ballcap) that tries (unsuccesfully) to wrap an ActiveRecord-style interface around a niche graph database that’s incredibly slow to fetch from and even slower to write to. Ballcap tries to mitigate this by caching data on writes and trying to read from the cache first on reads, but it’s really flakey and there are tons of subtle and non-obvious ways in which this can fail and which will trigger an inopportune read from the primary graph db. We needed a long term plan to begin refactoring away from this, while keeping our data in the graph db compatible with this squirrelly Ballcap library for the short-to-medium term.

      Approach is: Define a new root data model class, the LockedObject that always reads only from the cache, and never writes to anything. When you subclass LockedObject and define attributes on that model, it internally creates a subclass of Ballcap’s Base class with the same attributes. LockedObject defines getters for the attributes, and setters that throw an exception that tell you the object is locked and you need to pass a lambda to extreme_slow_long_awkward_unlock_method that can actually do mutation (the lambda gets passed an instance of the internal Ballcap class read from the slow graph db).

      You can define methods in an unlocked block on your LockedObject class to define custom methods that need to deal with mutation – these method definitions are actually performed in the context of the internal Ballcap subclass, so that you can call them in your lambdas passed to extreme_slow_long_awkward_unlock_method. The equivalent of forwardInvocations are used so that (mutating) methods defined on the internal Ballcap instance can call non-mutatng methods defined on the LockedObject transparently, and so that if you try to call a mutating Ballcap method on the LockedObject, you get a meaningful error telling you you need to unlock the object first.

      It’s complicated to describe but transparent in use, and has a very small implementation – it’s really just a couple of methods to shuttle invocations around, and some run-time subclassing and method definitions.

      It wins us a UI always driven off of the fast cache, and complete containment of our interactions with this crappy Ballcap DB inside extreme_slow_long_awkward_unlock_method invocations that are obvious and easy to find. We’re currently playing with moving the actual performance of the extreme_slow_long_awkward_unlock_method lambda to a background worker, and replaying the lambda onto a second, better, datastore, so that we can get a second master copy of our data completely out of Ballcap and then axe it.

    13. 2

      The trick was implementing nice RPC wrappers by automatically creating new classes at runtime. Introspecting the endpoint for the signatures and methods gave the information needed to generate these classes.

      1. 1

        Generating new types at runtime is something I hadn’t considered and now kinda feel like an idiot for that reason

    14. 2

      Kind of an overlap between dynamic typing and giving classes the ability to respond to unknown methods, but used to use a Smalltalk REST/JSON library that would write your code for you as you worked. Basically, in dev mode, you would call a method that didn’t exist, and it would then generate that method as a REST call to a URL based on fixed rules, then analyze the result and create a class to represent the result, and then return it. Sample usage looked something like this:

      "UserClient is a subclass of RESTClient with zero (meaningful) methods".
      "First, create a client, and turn on dynamic method generation."
      client := UserClient newOn: 'http://whatever.com/api'. "This would be a normal client"
      "Now it'll make any methods you call that do not exist."
      client beDynamic.
      "Smalltalk doesn't have real namespaces, so class prefixes are used. Set one for
      data classes."
      client usePrefix: 'LB'.
      "Okay, the next line is gonna generate a method, do a REST call, make a new
      class, and return an instance of it. Ready?"
      user := client userWithFirstName: 'bob' lastName: 'joe'.
      "The above a) creates a method called #userWithFirstName:lastName: on
       UserClient, b) has that method issue a GET request (based om naming conventions)
      with ?firstName=bob&lastName=joe, c) generated a new class called LBUser, d)
      built up that class based on whatever it saw returned from that endpoint, e) gave
      us an instance deserialized from the JSON. So:"
      Transcript show: user firstName. "presumably prints Bob."
      

      In production mode, calling missing methods would result in a normal doesNotUndestand (DNU) being thrown, and a JSON structure being returned that didn’t match what was expected would throw a serialization error. So in the above example, if the JSON returned lacked a firstName fields, serialization would fail.

      While this is all cool, I would point out that modern tooling in static languages, such as F# Type Providers, can trivially do the same thing, and (IMHO) in a saner way.

    15. 2

      If JavaScript objects like Array, Object or Window had their interfaces statically set by the browser, the current scheme of polyfills to provide new methods on old browsers couldn’t work so transparently.

      Dynamic typing’s especially handy when you want to retrofit code designed to accept an A to take an A’ (a different implementation of the same interface as A). For example, at work we had some configuration stored as plain Python dicts and lists and needed to change a particular part of it to update itself when it’s used. We could make something that looked like a dict but updated itself without touching the code that does the reading.

      There’s a whole world of different typing arrangements (mixes of static and dynamic, type inference, approaches to metaprogramming) so it’s hard to say you can’t do X with static typing. But you can say dynamic typing tends to make a lot of code generic by default, which can be handy. It does not take that much fanciness to write functional tools in Python, for instance.

      Personally I really enjoy the safety and easy code navigation that tools built on static type systems provide, enough that I’d use TypeScript or other things that add checks and tooling even where there’s no run-time performance payoff. But don’t want to deny where dynamic typing comes in handy.

    16. 1

      The web.

      1. 4

        OP: “I want to hear more about the kinds of tricks that would be hard or impossible to replicate in static type systems.”

        That certainly doesn’t apply to the web, as far as I can tell. Maybe you’re right in some respect, but your two-word post doesn’t give me a lot to go off of.

        1. 3

          Certainly parts of the web can be, and are, statically checked prior to their deployment. There is no static guarantee of this for any given component. Nor is there a guarantee, at runtime, the corresponding components will agree on the correctness of any interactions.

          Moreover the web in its entirety is a dynamic system. In fact has all of the characteristics of an “ultra-large-scale system” including the following characteristics that make “statically checking the web “ impossible at this point, if ever…

          • Have decentralized data, development, evolution and operational control
          • Address inherently conflicting, unknowable, and diverse requirements
          • Evolve continuously while it is operating, with different capabilities being deployed and removed Contain heterogeneous, inconsistent and changing elements
          • Erode the people system boundary. People will not just be users, but elements of the system and affecting its overall emergent behavior.
          • Encounter failure as the norm, rather than the exception, with it being extremely unlikely that all components are functioning at any one time
          • Require new paradigms for acquisition and policy, and new methods for control

          https://en.m.wikipedia.org/wiki/Ultra-large-scale_systems

          1. 2

            You said a lot of business procurement/marketing jumbo jumbo, but you haven’t explained why you think the web is inherently dynamically typed, which is the question. It’s also a technically specific question, which your answer seems to ignore entirely.

            Here’s why I think it’s not: there are nice strongly typed frameworks for any kind of web interaction. QED “web stuff” can be nicely expressed in a strongly typed way.

            1. 2

              As I wrote, specific components can be statically checked. No component can rely on any other distributed component being similarly checked or completely compliant.

              I guess if you consider any of what I listed in my previous message above as “marketing mumbo jumbo” then we have little further to discuss.

          2. 1

            I see buzzwords, but not much meaning – and definitely no explanation of why these buzzwords imply dynamic typing is necessary.

            1. 2

              Choose one item from the list you see as particularly buzzwordy and not meaningfully describing the dynamism of the web and let’s discuss it.

              1. 1

                “Dynamism” isn’t the same thing as “dynamically typed”.

                This is incredibly buzzwordy: “Require new paradigms for acquisition and policy”. That literally doesn’t mean anything.

                1. 1

                  Well that definitely came out of the aerospace / military industry as they grappled with what became known as ultra-large-scale systems that far exceeded the traditional contracting, design, and implementation they had been used to for decades.

                  This definitely characterizes the web vs. the software industry prior to the web. Networked systems prior typically had tight control, if not ownership of, both clients, servers, and the protocols between them. Acquisition of almost every aspect of the web is different from the ground up, networking, hardware, software, client and server. I’d be hard pressed to name anything significant that remains as it was. As for policies, the same. (Think about the changes is protocols, security, reliability, privacy, etc) Maybe you weren’t around for what passed for large scale computing prior to the web. I can go into detail if desired.

                  But what do you mean by “dynamism is not the same thing as dynamically typed”? Dynamism is essentially accommodating change that had not been planned a priori, ie the types are not known or are not significant because they can be accounted for dynamically, through some kind of dynamism.

      2. 3

        You must consider HTML and/or HTTP to be dynamically typed. I haven’t thought about it hard. When I did web stuff, they had specific tags/actions requiring specific types of data in specific ranges in some fields and open-ended in others. That seemed statically typed to me even if you didn’t have to write an annotation. You always know what kinds of data the various tags or headers will contain even down to bounds of some.

        1. 4

          HTML is interesting (having not thought about this before) by being typed in the sense that you have to specify the types of the tags, and there’s a bunch of reasonable default behavior that comes with that, but then those types (and the runtime environment - the browser) are amenable to all kinds of overloading, partly-correct or outdated usage, and just making any particular tag behave however you want (like in post-table, pre-HTML5 days when it was all the way down). So it’s like… pointlessly typed?