Take Your Linting Game to the Next Level!

Take Your Linting Game to the Next Level!

Enhanced Static Code Analysis with Clj-Kondo and Malli

TLDR: It is possible to declare types for Clojure functions and make them available to the library users by creating clj-kondo configs (type exports). The configs can be created with Malli by collecting function schemas and then transforming these into clj-kondo types in a config file. If the config files are committed into resources, they can be imported by the host project when used as a dependency. Having Malli schemas has the added benefit of enabling the run-time checks via Malli instrumentation.

I like imagining an ecosystem where there'd be a clj-kondo config available for the most used libraries. I thought I would chime in my two cents on spreading the know-how to spread the word on how to do this. I'm by no means an expert on the topic, but I've learned a thing or two that might also be helpful to others.

Suppose you are unfamiliar with Malli and clj-kondo and how they can help you with static code analysis. In that case, I recommend first reading Data Validation in Clojure and Typescript Like Intellisense for Clojure Functions With Malli for background.

This time, I want to dig deeper into how these tools can improve the developer experience by making the types available to our code's human and CI consumers. I don't know if this is already yesterday's news, but this trick is worth knowing.

Let's explore the topic by creating a library, publishing it to Github, then using it as a dependency for another project, and making the library types available to benefit from the extended clj-kondo features.

Publish Library With Types

The only required dependency is Malli, which will be used to generate the clj-kondo types.

Note that in a "real" project Malli would be added as an extra dependency for an alias when creating the types if the library itself is not depending on Malli. If this is something that doesn't make sense to you let me know in the comments.

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        metosin/malli {:mvn/version "0.13.0"}}}

My library will have two functions: adding (x,y) coordinate points and rendering points as a string. If you need help defining the schemas for your use case, check out Malli function schema docs for reference.

(ns tvaisanen.types-export-sample.core
  "Example on how to create and export type clj-kondo
  type definitions that can be installed in a similar way
  that @types/lib-name is used in Typescript.")

(def Point [:map
            [:x :int]
            [:y :int]])

(defn add-points
  {:malli/schema [:=> [:cat Point Point] Point]}
  [p1 p2]
  (merge-with + p1 p2))

(defn render-point
  {:malli/schema [:=> [:cat Point] :string]}
  [p]
  (str p))

The only difference from what you'd typically write is the added metadata key pair. This will be enough for us to get the benefits in later stages when we want to provide the type hints for the library users. Let's take a look at how to make that happen next.

Export Configuration

Exporting is the process of creating the library's clj-kondo configuration under the resources folded as the documentation instructs. To do this, I created a naive exporting function that:

  1. Also collects the malli/schema schemas from the functions,

  2. generates the type definitions under the Malli clj-kondo-types,

  3. copies the config.edn files into resources,

  4. and cleans the clj-kondo-types cache.

(require '[malli.dev :as dev]
         '[clojure.java.io. :as io])

(defn export-types []
  ;; collect schemas and start instrumentation
  (dev/start!)

  ;; create export file
  (def export-file
    (io/file "resources/clj-kondo/clj-kondo.exports/tvaisanen/export-types-sample/config.edn"))

  ;; make parents if not exist
  (io/make-parents export-file)

  ;; copy the configs
  (io/copy
   (io/file ".clj-kondo/metosin/malli-types-clj/config.edn")
   export-file)

  ;; clear the cache and stop instrumentation
  (dev/stop!))

If we inspect the .clj-kondo cache folder, we can see that there's a config for malli-types-clj which is used to store the temporary cache while the Malli instrumentation is active (dev/start!) and it's cleared on (dev/stop!).

❯ tree .clj-kondo
.clj-kondo
└── metosin
    └── malli-types-clj
        └── config.edn

What we just did with the export function was copy this config.edn file from the clj-kondo cache into our libs resources for persistence.

❯ tree resources
resources
└── clj-kondo
    └── clj-kondo.exports
        └── tvaisanen
            └── export-types-sample
                └── config.edn

5 directories, 1 file

This is the folder structure that clj-kondo expects to see when it's looking for available configurations to be imported. In my case the config.edn looks like this.

{:linters
 {:unresolved-symbol {:exclude [(malli.core/=>)]},
  :type-mismatch
  {:namespaces
   {export-types-sample.core

    {render-point
     {:arities
      {1 {:args [{:op  :keys,
                  :req {:x :int,
                        :y :int}}],
          :ret  :string}}},

     add-points
     {:arities
      {2 {:args [{:op  :keys,
                  :req {:x :int,
                        :y :int}}
                 {:op  :keys,
                  :req {:x :int,
                        :y :int}}],
          :ret  {:op  :keys,
                 :req {:x :int, :y :int}}}}}}}}}}

From here, we can see that argument and return types are defined for two functions render-point and add-points that have. This tells clj-kondo how the function arguments and return value should look when it's doing the static code analysis.

We are almost done with the first step: creating our library with typehints. Next, commit and push the changes to your Git repo. Here's mine for a reference.

Load the Published Library as a Dependency

Now that we have "published" a library with clj-kondo configs, we can use this as a dependency for another project.

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}

        metosin/malli {:mvn/version "0.13.0"}

        tvaisanen/export-types-sample
        {:git/sha "29f9b259975bde304300e4ca69c70d61622cabab"
         :git/url "https://github.com/tvaisanen/export-types-sample.git"}}}

If you didn't use Github see the available gitlib config options from the docs.

When we fetch the source code for the dependencies, we receive the resources (read configs) in our local cache and the malli function schemas within the source code. We don't need to install additional libs or types to access them. Next, we must collect these configs to our project's .clj-kondo cache. Clj-kondo docs provide the steps on how to do this. Remember to create the .clj-kondo folder if it doesn't exist before copying the configs.

First, we copy the configs.

❯ clj-kondo --lint "$(clojure -Spath)" --copy-configs --skip-lint

Imported config to .clj-kondo/tvaisanen/export-types-sample. 
To activate, add "tvaisanen/export-types-sample" to 
:config-paths in .clj-kondo/config.edn.

Then update the .clj-kondo/config.edn (you might need to create the file) as instructed by clj-kondo output.

{:config-paths ["tvaisanen/export-types-sample"]}

And I am then populating the cache with.

$ clj-kondo --lint $(clojure -Spath) --dependencies --parallel

After this, we should be good to jump into the editor and see if everything worked as expected.

Benefits of Types in the Editor

I created a core namespace for the new project where I've required the library I set up. You can see that when add-points is called with an invalid sample/Point it tells us that we are missing a required key :y. Without the extra type config, we should have an issue with "duplicate key :x", so it looks like the lib configs trump the default linting issues in priority.

As we extend the code, we can see that add-points return a value that the render-point is okay with according to clj-kondo, but if we pass a map that is not a sample/Point we get another linting error on the screen.

These red squiggly lines can save you a lot of headaches by letting you know where the types don't match the expected value. But this is just the first benefit that comes from using the types. So far, we haven't looked into how Malli expands on this.

Benefits of Types in the REPL

If a library has Malli schema function definitions, we can start instrumentation (monitoring with what values the functions are called and what they return) and get run-time type checking. In practice, this means we get a report on "invalid function call" in the REPL every time a function is called with unexpected arguments.

Let me demonstrate this.

We can use this to our advantage since we created the library with the malli schemas instead of writing the type hints manually into the clj-kondo config. Let's require malli.dev in our namespace and run dev/start! as we did when we first exported the types. Now, try running the sample/render-point with an invalid point argument.

When we evaluate line 19 while the malli instrumentation is active, we can find something new in the REPL, a schema error nicely documenting what is happening. The REPL provides helpful information about what went wrong with the function call.

Of course, we must remember that if there are a lot of functions being called, having the instrumentation on all the time can have a performance penalty.

Just keep in mind when running dev/start! the types are created into .clj-kondo/metosin/malli-types-clj/config.edn and when the dev/stop! is run. These types will be cleaned up.

Benefit of Types in the Terminal / CI

Last but least, we can also use the generated types outside our editor by using clj-kondo from the command line.

❯ clj-kondo --lint "src"
src/core.clj:6:2: error: Missing required key: :y
src/core.clj:6:8: error: duplicate key :x
src/core.clj:13:22: error: Missing required key: :x
src/core.clj:13:22: error: Missing required key: :y
linting took 5ms, errors: 4, warnings: 0

Adding this step can help keep many unnecessary bugs out of production when added to the continuous integration checks.

Where to Contribute

If you find that your particular use case is not supported for some reason. Providing patches is encouraged. Malli defines the malli->clj-kondo transformations in malli.clj-kondo namespace, which is where you want to look if you want to learn how the transformation process works or if you're going to extend the functionality. On the clj-kondo side, read the status of types and this source code file listing the available type mappings.

Conclusion

That was probably a lot to digest in one go. It took me some time to understand the relationship between clj-kondo and Malli, but slowly and surely, it's making more sense. I wanted to write about this topic because I think if more people knew how to add the types to their projects, it would improve the developer experience across the whole ecosystem (at least, this is what I'd like to imagine).

This is somewhat what happened with Javascript and Typescript. Little by little, the majority of the libs started offering type definitions. I don't know about you, but I'd appreciate a little extra help from my editor in debugging the expected types for a given function and having the change occasionally to turn on the Malli instrumentation to provide some run-time info on what's going wrong.

Some tools can provide the observability to run-time values like Flowstrom or just good old clojure/tools.trace. However, these tools do not tell me what type of data the original author had intended to receive.

Once again, I hope you found this helpful. Feel free to reach out and let me know what you think—social links in the menu.

Thank you for reading.