Skip to content

Coding style

Joshua Shinavier edited this page Jun 5, 2026 · 13 revisions

Guiding principles and coding conventions for the Hydra project. These apply to all Hydra implementations and to contributions from both humans and AI assistants.

Guiding principles

Strictness and precision

Hydra is a strongly-typed language built on the composition of functions. It aims for mathematical precision, and does not tolerate or hide failures.

  • If there is an error, the application should fail immediately with an informative error message. Whether a resource fails to load, a mapping encounters an unexpected condition, or a type check fails, that is the end of the workflow.
  • Hydra code should never be written to recover from errors, except in limited cases involving I/O. Do not catch exceptions and return defaults. Do not use Data.Maybe.fromJust, Maybes.fromMaybe defaultValue, or equivalent patterns to silently paper over failures. (Hydra removed hydra.lib.maybes.fromJust as a primitive in v0.15.0 for this reason; if you need to collapse a Maybe in a way that expresses "this cannot be Nothing," return Either Error a and propagate the error instead.)
  • Error messages must be informative. Include context about what was being attempted and what went wrong. In Haskell, use fail $ "Context: " ++ formatError err in I/O code, and propagate Either Error values in pure code (threading a Context alongside for trace messages).
  • No post-generation patches. Never write a script to "fix" generated code after the code generation process runs. If there is a flaw in code generation, investigate and fix it at the source.
-- CORRECT: Fail immediately with context
case CodeGeneration.generateSourceFiles ... of
  Left ic -> fail $ "Failed to generate source files: " ++ formatError ic
  Right files -> do
    mapM_ writePair files
    return $ length files

-- WRONG: Silently swallow errors
let result = fromMaybe defaultTerm (lookupElement name graph)

-- WRONG: Catch and continue
case lookupElement name graph of
  Left _ -> return emptyTerm  -- Error is hidden
  Right e -> return e

Translingual design

Hydra is a translingual programming language. Even code which is specific to one host or target language should be written in such a way that it can be generalized to other languages in the future.

  • Take advantage of language-specific features, but always write code with an eye toward portability.
  • The Hydra kernel is code-generated into Haskell, Java, Python, Scala, TypeScript, and four Lisp dialects (Clojure, Scheme, Common Lisp, Emacs Lisp). Design choices in one implementation should not preclude the same logic working in another.
  • Avoid idioms that are deeply tied to one language's runtime (e.g., Java-specific exception hierarchies, Python-specific dynamic dispatch patterns) when writing kernel-level code.
  • When adding primitives, implement the same semantics across all six full implementations. See the adding primitives recipe.

Standardization and consistency

Writing code in a standardized way improves maintainability and extensibility.

  • Definitions are always provided in alphabetical order so it is easy to find the function you are looking for. This applies to the list of definition names in module sources and to the definition definition bodies which follow.

  • Conventional names are reused across namespaces to decrease mental load. When similar operations exist in different modules, they use the same name. For example, ns, define, and module_, appear in every source module with the same roles. Hydra Core terms like term, field, record, etc. appear in many different DSLs used for different purposes in the kernel.

  • Type-indexed modules reinforce this consistency. Hydra's source code is organized into families of modules that mirror the type modules. For a given type module like hydra.core (which defines AnnotatedTerm, AnnotatedType, Application, etc.), there are parallel modules:

    • hydra.show.core -- with functions annotatedTerm, annotatedType, application, etc.
    • hydra.encode.core -- same function names, different operation
    • hydra.decode.core -- same function names
    • hydra.extract.core -- same function names
    • hydra.error.core -- same function names
    • hydra.path.core -- same function names
    • hydra.dsl.core (generated DSL) -- same function names

    Each family member provides a different operation (showing, encoding, decoding, extracting, etc.) on the same set of types, and the functions are always named after the type they operate on. This makes the codebase highly navigable: if you know the type name and the operation you need, you know the function name.

  • Import conventions are consistent across files. The same modules are qualified the same way everywhere (e.g., Lists, Strings, Core, Graph). See import conventions below.

Additionally, in the Hydra kernel:

  • Simple names only: kernel modules have names like hydra.core or hydra.show.packaging, never capitalized or camel-cased names like hydra.someModule. Test modules are not part of the kernel, and are not subject this rule; e.g. hydra.test.checking.algebraicTypes.

Coding conventions

Module structure

Every Hydra source module follows this layout:

  1. Module-level Haddock comment
  2. Module declaration
  3. Imports (see import conventions)
  4. ns (namespace) definition
  5. define helper (created with definitionInNamespace ns or definitionInModule module_)
  6. module_ definition with elements list
  7. Function definitions, in the same order as the elements list
-- | Description of what this module provides.

module Hydra.Sources.MyModule where

import Hydra.Kernel
import Hydra.Sources.Libraries
-- ... other imports ...

ns :: ModuleName
ns = ModuleName "hydra.myModule"

define :: String -> TTerm a -> TTermDefinition a
define = definitionInModuleName ns

module_ :: Module
module_ = Module {
    moduleName = ns,
    moduleDefinitions = definitions,
    moduleDependencies = unqualifiedDep <$>
      ([dependencies] L.++ kernelTypesModuleNames),
    moduleDescription = Just "Description of the module."}
  where
    definitions = [
      toTermDefinition alpha,
      toTermDefinition beta,
      toTermDefinition gamma]

alpha :: TTermDefinition (...)
alpha = define "alpha" $ ...

beta :: TTermDefinition (...)
beta = define "beta" $ ...

gamma :: TTermDefinition (...)
gamma = define "gamma" $ ...

Definition ordering

Definitions in the elements list and their corresponding implementations must be in alphabetical order. This makes it easy to locate any definition by name without searching.

-- CORRECT: Alphabetical
definitions = [
  toTermDefinition compactName,
  toTermDefinition localNameOf,
  toTermDefinition moduleNameOf,
  toTermDefinition qualifyName,
  toTermDefinition unqualifyName]

-- WRONG: Arbitrary or "logical" ordering
definitions = [
  toTermDefinition qualifyName,
  toTermDefinition unqualifyName,
  toTermDefinition compactName,     -- Out of place
  toTermDefinition moduleNameOf,
  toTermDefinition localNameOf]     -- Out of place

Import conventions

Hydra Haskell DSL source files follow a consistent import structure across eight categories of source module. The general rule is: qualify by default, and unqualify only the small set of modules that are central to what the file builds.

For the canonical import block of each category, see Import conventions in the main repo. The eight categories are:

  1. kernel type modules — Hydra-kernel types
  2. type-level sources outside of the kernel — Per-language types (host/target ASTs)
  3. kernel terms modules — Hydra-kernel term constants
  4. kernel terms modules with DeepCore — Encoders/decoders/extractors
  5. term-level sources outside of the kernel — Per-language term constants (coders, etc.)
  6. tests — Test groups whose @@ is binding application
  7. term-encoded tests — Test groups whose @@ builds Hydra-runtime term applications (lambdas, primitives)
  8. kernel test fixtures — Named term/type/binding fixtures used by tests

Each category has a canonical comment tag (the names above in backticks) placed above its import block. When creating a new module, copy the import block from an existing module of the same category.

Naming conventions

Category Convention Examples
Functions camelCase normalizeComment, gatherApplications
Predicates is prefix isSimpleAssignment, isComplexTerm, isTrivialTerm
Types PascalCase Term, Type, Binding, Module
Module names dotted lowercase hydra.coderUtils, hydra.json.decode
Module aliases short, consistent Lists, Maps, Core, Graph (not L, M, C, G)
Standard lib aliases single letter Data.Map as M, Data.List as L, Data.Maybe as Y
Name constants underscore prefix _Term, _Type_record, _Person_name

Error handling

In DSL code (pure, kernel-level):

  • Use Either to represent computations that can fail.
  • Never return a default value on failure. Propagate the error.
  • Provide context in error messages.

In I/O code (Generation.hs and similar):

  • Convert Either results to fail immediately.
  • Include a descriptive prefix: fail $ "Failed to X: " ++ formatError err.
  • Never silently catch and discard errors.

Comments

  • Use -- | (Haddock style) for function documentation.
  • Use -- for inline clarification of non-obvious logic.
  • Comments should explain why, not what, unless the code is genuinely obscure.

Doc-string formatting conventions

Hydra has two kinds of doc text, with different formatting rules:

  • Host-local comments — Haskell -- |, Java Javadoc, Python docstrings, Scala Scaladoc, etc. Use each host's native conventions:

    • Haskell: Haddock — @code@ for inline code, 'identifier' for cross-references.
    • Java: Javadoc — {@code name}, {@link name}.
    • Python: project-native (Sphinx reST, mkdocs Markdown, etc.).
    • Scala: Scaladoc — `code`, [[name]].
    • TypeScript: TSDoc — `code`, {@link name}.
    • These comments never leave their host source file.
  • Translingual doc "..." strings — supplied to the Hydra DSL when defining a type, term, or field; flow through dist/json/ into every host's generated code. Use Markdown-style backticks (`name`) for inline code references. Per-host coders are expected to translate backticks into the host's monospace convention or strip them. Don't use Haddock, Javadoc, or other host-specific markup in translingual doc strings.

The cross-reference question (how to link an identifier in a translingual doc string to its declaration) is open — see #433. Until that lands, use plain identifier names (no link markup) inside translingual doc strings; backticks are for code formatting only.

Common mistakes

These patterns recur in AI-generated contributions. Avoid them.

Silently tolerating failures

-- WRONG: Returns a default instead of failing
lookupOrDefault name graph = fromMaybe emptyTerm (lookupElement name graph)

-- WRONG: Catches an error and continues with degraded behavior
processTerms terms = filter isRight (map processTerm terms)  -- Silently drops failures

-- CORRECT: Fail immediately
lookupOrFail name graph = case lookupElement name graph of
  Nothing -> fail $ "Element not found: " ++ show name
  Just e -> return e

Definitions out of order

-- WRONG: Not alphabetical
definitions = [
  toTermDefinition zebra,
  toTermDefinition apple,
  toTermDefinition mango]

-- CORRECT: Alphabetical
definitions = [
  toTermDefinition apple,
  toTermDefinition mango,
  toTermDefinition zebra]

The function definitions that follow the elements list must appear in the same (alphabetical) order.

Inconsistent import qualification

-- WRONG: Using a non-standard alias
import qualified Hydra.Dsl.Meta.Lib.Lists as L      -- Should be Lists, not L

-- WRONG: Unqualifying a module that should be qualified
import Hydra.Dsl.Meta.Lib.Maps                       -- Should be qualified as Maps

-- WRONG: Qualifying a module that is conventionally unqualified
import qualified Hydra.Kernel as K                    -- Should be unqualified

See also

Clone this wiki locally