Code Smell of the Day: Type Keys
jesseduffield.com
jesseduffield.com
One thing you should avoid for readable code is callbacks and interfaces. This may run counter to advice you may find in books, but programming practice should've taught you that use of these features makes it exponentially harder to figure out "what's going on here". They are tools to solve specific problems, not defaults to apply liberally.
You must work in a nice place - I often find code that does not have an ideal layout.
For example, in this case there are really only two user creation types, which could be modeled with just an "isAdmin" flag. However, the likelyhood that a third type (or more) will appear down the road is high, so it is reasonable to speculatively use an enum-like type here.
Every call site has to decide whether the type argument is admin or user. After the change every call site has to decide which of the two create functions to call. The differentiation already existed for a reason not explicit at the place where create* was defined (although in this trivial,example it’s obvious).
My main motivation for writing the post was seeing type keys used for truly no good reason and leading to code that was impossible to understand, but the post could spend more time considering when type keys are appropriate and the downsides of alternatives.
I would probably create two functions, createAdmin and createCustomer, then as the post briefly does, factor out commonalities where it makes sense. Note that factoring out ALL commonalities is often a red herring, some duplication may be preferable to strangely factored code (eg the callbacks…)
It is not.
This kind of refactoring reminds me of a developer we used to have who would do this sort of thing to the entire codebase. Every slightly-overweight method that took care of an entire concern scattered to the seven winds of "best practices" philosophy. Rinse and repeat enough times and you find yourself taping source code printouts to the wall and tying strings between them like its a CSI episode.
I would heartily support the refractors given in the examples if they reached me in an MR. But chances are that a type key is introduced in 1 location, and then acted on in 6 different locations with a large temporal distance. Without the type key you are likely to need some way to infer the type, which may be error prone and hard to maintain.
On a micro scale a common example is where you have to pass over a list of statements to declare the functions, and a second time to define them. So that they can be self referential. Preferably without N scale memory allocations caused by things such as filtering the list.
One significant downside, which I think would better support the article’s point, is that type keys make composition (or inheritance if that’s your thing) more awkward. To represent an intersection type, you need either a nested structure (more flexible) or implicit relationships between types (please do not).
Now, if you have a large system and many more of these “type keys”, by all means, this is an appropriate solution. But in the example of user type / creation, I beg to differ: the earlier example is much simpler to understand and reason about.
Imagine you are trying to debug an issue with user creation.
In the last example, you would have to look up everywhere `createUser` is being called, and follow the code path through several different scattered files until you find your issue.
In the original code, you can simply look up `createUser` and you have the complete code flow in front of you.
enum UserType {
ADMIN { void setup(User) { … } },
CUSTOMER { void setup(User) { … } },
;
abstract void setup(User);
}
and then have: User createUser(UserType type, UserAttributes attributes) {
User user = new User(attributes);
type.setup(user);
user.setupNotifications();
return user;
}The more explicit version may still be the better trade-off, but in general it’s not a clear-cut question.
Eg. I have noticed some collegues want a functional programming like flow of constant value objects, but they don't think about that they are essentially storing the state in the code path instead. Etc.
With time, somebody will probably encapsulate his code in a flexible "createUser" function on an upper level, so the switches can all come back into a single place.
"Admins are Users except not really"
If Admins are really Users, then create a User first and then elevate to Admin with ACL assignments, etc. There should probably be accompanying inverse procedures too.
If Admins are not really Users (i.e. there is a strong segregation between internal "users" and customer "users"), then avoid coupling their logic at all.
I respect that once you reach a certain level of competence you can probably coast by without thinking too much, but let's not pretend comfortable stagnation is the same thing as mastery.
I’m writing code to solve problems, I’m not here to discuss the abstract art of code patterns and structure.
Take two people with similar experience on the language and idioms you use, and they will very likely agree on what code is easy or hard to understand.
Seems to me, a lot of "DSL"/"API design" work is really about passing some compile-time data structure to a function that is too complex to be represented as a simple list of parameters.
A lot of times, this is done by abusing language features or by setting up a runtime data structure that is then immediately unpacked again inside the function.
In the most extreme cases, you design a custom DSL, have the caller pass expressions in that DSL as a string or file handle to the function and then parse the string inside the function.
All of that causes a lot of runtime complexity and overhead for what is essentially static data.
So wouldn't it make more sense if you could define a DSL in a function signature and connect it with preprocessor/macro statements inside the function? This way, the compiler could parse the DSL during build and we could get rid of all the runtime overhead.
Example: A fictional function definition could look like this: (where # indicates a keyword destinied for the preprocessor)
public Response fetch(String url, Map headers, (#keyword method=GET) #or (#keyword method=POST, byte[] body)) {
// ... common code
#switch (method) {
#case GET:
// ... GET-specific code
#case POST:
// ... POST-specific code
}
}
A compiler would generate two separate methods from this definition. (To avoid C macro madness, you'd probably first generate an AST, then have the preprocessor modify that AST.)A call site could look like this:
result = fetch("example.com/", {}, GET);
result = fetch("example.com/", {}, POST, data);
Which would call one of the two methods in a fashion analogous to method overloading.Disagreed on the AST though. I think a lot of problems can be solved if a programming language has a well-defined publicly available grammar and if access to its AST is easy. As an example, a lot of innovation in modern IDEs comes from the IDE's ability to actually parse the code files and build object graphs. Knowing details about the AST is necessary for that.
That was a fantasy syntax I just made up. How can I expose implementation details of a compiler that doesn't exist?
Defunctionalised programs pass around a simple data type, which is branched on in various places to implement different behaviours. Re-functionalised programs pass around different behaviours (as functions, or objects if you insist) which are called in various places.
Neither form is strictly better than the other; it depends on the problem we're trying to solve.
[1] https://en.wikipedia.org/wiki/Dependency_inversion_principle
I am in injecting a function that returns a `User` type. That is the interface. I, the function accepting the injected function, don't care how it gets that `User` (that's the implementation). I just care that I am getting a `User`.
Now, knowing that, I can inject any number of function implementations that implement that interface. As long as a `User` gets returned it does not matter how the sausage gets made so to speak.
[1]: https://craftinginterpreters.com/representing-code.html#the-...
This is a very simple pattern with Python, instead of passing literals, create the different literals as types, and make the function accept a union of said types. Then use `functools.singledispatch`, to map each type to the correct function. This results in three separate functions, just as one would do with algebraic data types.
Or, instead of singledispatch, use dictionary mapping `type(key)->Callable`, and pass the arguments there, making the graph identical to the second.
It's also possible to give this type a name:
type UserCreationType = 'admin' | 'customer'But in a case like this, where a flag or enum is dispatching between two distinct code paths, maybe it's worth considering separation into distinct functions.
create(entity, bool_a)
we have a function that creates an entity and take a bool flag, and it has 2 possible variants (entity + true, entity + false). If we add another bool flag create(entity, bool_a, bool_b)
then it increases to 4 states. Add another and you're dealing with 8 states, and so on and so forth. We likely don't care about all 8 states - rather 4 or 5, but complexity increases.In this case, with the type key, we're essentially dealing with a sum type that has two variants
(admin, attributes) | (customer, attributes)
which is fairly clear. if we add another variant to it, we only increase by one (admin, attributes) | (customer, attributes) | (distributor, attributes)What would be the point? To try and ensure that certain setup measures are taken in all cases?
Do people actually write code like this? Maybe this is a programming language culture difference, but I never see this pattern in my Python work, professional or otherwise.
I think my issue is that this post takes a very narrow view of what Boolean flags are used for. I would hope that they are not advocating against things like `fetch(url, verifySsl = false)` !
For the example you gave, I think it’d be a much better design to have `fetch` and `fetchNoVerify` because this would make it much easier to audit the code to see if SSL verification is ever skipped.
> We likely don't care about all 8 states - rather 4 or 5, but complexity increases.
When you have only 2 valid use-cases, you need the code for both. It has to exist. When you have 5 valid use-cases and 3 invalid use-cases, making those 3 invalid use-cases impossible leads to better maintainability. So the minimum code example isn't actually the minimum code example to show why it is effective.
And yeah, product types and the ability to ensure that if you don't handle a case that is possible you get a compiler error is clearly the ultimate result the blog author is searching for.
A closely related issue is configurable libraries: far too many libraries become immensely configurable, but any one deployment is not using most of the code. (Give me a 10KB JavaScript library and the subset of its functionality used, and I can normally strip it down to maybe 2–3KB that will execute a good deal faster.)
What I’d really like for such cases is to be able to mark certain functions’ arguments as to be evaluated at compile-time—value monomorphisation, like type monomorphisation with generics. Or even mark arguments as allowed to be evaluated at compile time, so that the compiler can judge what’s optimal itself.
This is the sort of stuff that’s done as a matter of course in languages that compile to machine code (by compilers like GCC or LLVM), but it’s baffling how little effort has been put into anything like this at compile-time, given how much effort has been put into making execution fast. There are basically only three even slightly interesting things, and they all give you the choice between being extremely inferior, or being somewhat inferior and inconvenient:
• Google’s Closure Compiler’s advanced optimisations mode was good for its time, but hasn’t kept up (runtime tooling support in things like browser dev tools is nonexistent so that it’s painful to work with its output, and it needs TypeScript integration, and seriously, look at https://developers.google.com/closure/compiler/docs/api-tuto..., it gives a Python 2.4 snippet there).
• UglifyJS/UglifyES/Terser also live in the JavaScript era rather than the TypeScript era, so they miss huge opportunities, and their dead code detection and removal optimisations are pitiful by comparison with machine code compilers, being type-unaware and typically requiring inlining (regularly infeasible) before they might be able to do something. (Being type- and aliasing-unaware also thwarts a lot of practical code rearrange where a canny human can reorder things to shrink the resulting code and make it faster—again something regular compiled languages support, with Rust head and shoulders above the competition because of its ownership model.)
• Facebook did start the one interesting project in this area of the last decade, Prepack, but it’s fairly limited in its suitability because it’s too likely to break things unless you handle it with great care (similar to Closure Compiler’s advanced optimisations in that regard, and utterly unlike languages that are designed to be optimisable), and they seem to have given up on it now.
I don’t think your argument is strong here.
Code paths where an overridden method in a subclass calls back into a method on the base class are much clearer too, as they have to be explicitly passed in as parameters, the sub-class doesn't have access to any of the parent class's state or methods by default.
That's why we try to follow liskov, and with your suggestion you are breaking this assumption/convention.
I have argued given the current puzzle, not additional “down the line” puzzle.
So a better class relationship is
User has a Role. Role can be an interface class with multiple implementations.
This design trivially lends itself for example to a use case where the user holds multiple roles. The classic OO design would use some form of "multiple" inheritance resulting in complicated class hierarchy.
I also like that in business logic or in presentation layers, wherever you go, various states are still consistently and clearly described. You end up with fairly cohesive types of functions which expect to do fairly specific things to specific shapes of data. To me this is a lot easier to manage than larger functions which do more based on a lot of conditions. The debugging experience is dramatically improved. Instead of debugging inside of if statements, you get to go upstream and check why something was assigned the wrong key in the first place.
One real-world example is search results in an app I maintain. The result types can either be "loading" (I know it'll exist but I don't have a response yet), "partial" (I got a response but some of the data could be missing or stale), or "complete" (this has all of the data I could ask for and I know it's fresh).
When a result is loading I don't want to render the final component which contains a lot of business logic that's dependent on a complete response from the server - shoving it in there and using a lot of conditions to avoid implementing that logic would create a complex component with very high surface area for bugs. Instead I keep a skeleton component which indicates that it's loading. It's actually the base for the partial and complete components, so I don't need to maintain it all that separately. Next, when a response is 'complete enough' (potentially stale or missing certain attributes) I render the partial component with slightly different logic and fields from the complete component. For a perfect result, I want to use its corresponding "complete" component.
So there's sort of a progressive enhancement happening that's very clearly described by the type keys and components, and I find it lets the code 'self-document' to a great degree. In terms of performance, I also know the UI will be re-rendering based on new data streaming in anyway, so rendering smaller and less complex components each time can actually be faster than relying on one big one.
Please, someone explain why this is a bad idea! I love to learn and I'm not married to this approach at all - I'm self taught and have tons of bad ideas.