Skip to content

Add attached derive clauses to data and newtype declarations#4594

Open
i-am-the-slime wants to merge 8 commits into
purescript:masterfrom
i-am-the-slime:attached-derive-clauses
Open

Add attached derive clauses to data and newtype declarations#4594
i-am-the-slime wants to merge 8 commits into
purescript:masterfrom
i-am-the-slime:attached-derive-clauses

Conversation

@i-am-the-slime
Copy link
Copy Markdown
Contributor

@i-am-the-slime i-am-the-slime commented Apr 19, 2026

Closes #3426.

Summary

Adds derive clauses directly on data/newtype declarations:

data Colour = Red | Green | Blue
  derive (Eq, Ord)

newtype Name = Name String
  derive (Eq, Ord)

data Box a = Empty | Full a
  derive (Functor)

Multiple classes per clause, multiple clauses per type, free mixing with standalone derive instance.

For higher-kinded classes, the number of type variables to apply is computed from the class's first parameter kind — Functor/Foldable/Traversable drop 1, Bifunctor drops 2, etc. No syntax is needed to specify it.

Only natively derivable classes are supported in derive clauses. Non-derivable classes are rejected with a CannotDerive error.

This is a reduced version of #4592, scoping out derive newtype and derive via to keep the diff small.

How it works

  1. New CST types (DeriveClass, DeriveClause) and grammar rules
  2. CST-to-AST conversion emits a temporary DeriveClauseDeclaration per class
  3. TypeClasses.hs desugars each into a TypeInstanceDeclaration with DerivedInstance body (the same node derive instance produces) and hands it to the existing handler
  4. The number of type variables to apply is read from the class's first parameter kind via kindArity — no per-class lookup table

@i-am-the-slime i-am-the-slime force-pushed the attached-derive-clauses branch 11 times, most recently from 249ffef to ca54e4a Compare April 25, 2026 10:21
@i-am-the-slime i-am-the-slime marked this pull request as ready for review April 28, 2026 19:00
@i-am-the-slime i-am-the-slime marked this pull request as draft April 28, 2026 19:01
@i-am-the-slime i-am-the-slime force-pushed the attached-derive-clauses branch 2 times, most recently from 85e4034 to 8bf8c27 Compare April 28, 2026 19:06
@i-am-the-slime i-am-the-slime marked this pull request as ready for review April 29, 2026 11:12
@i-am-the-slime i-am-the-slime force-pushed the attached-derive-clauses branch from 8bf8c27 to f973fdf Compare May 1, 2026 13:21
Adds Haskell-style derive clauses directly on data/newtype declarations:

  data Color = Red | Green | Blue
    derive (Eq, Ord)

  newtype Name = Name String
    derive newtype (Eq, Show)

Multiple classes per clause, multiple clauses per type, and free mixing
with standalone derive instance syntax. Automatically infers the correct
type application for higher-kinded classes like Functor.
@i-am-the-slime i-am-the-slime force-pushed the attached-derive-clauses branch from f973fdf to b056b2b Compare May 1, 2026 13:46
Copy link
Copy Markdown
Contributor

@natefaubion natefaubion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't find this particularly controversial, but it seems a little odd to have a different AST type just to run everything through TypeInstanceDeclaration anyway. It doesn't seem like you are utilizing this node in any useful way (such as for targeted errors). You should still have everything you need in Convert just to go ahead and generate a TypeInstanceDeclaration? It may be a little odd to have that desugaring in Convert, however.

Relying on the kinds in the environment works for these hard coded use cases, but I'm not sure how that works for subsequent features. It essentially means you can only derive things with this syntax as long as the class you want to derive is in an upstream module since this all happens during desugaring. This might be problematic for the more complicated extensions like newtype/via as you might want to derive a class in the same module as where you define it. Not necessarily a deal breaker for this as-is, but it seems like a big open question.

-- A derive clause attached to a data or newtype declaration
-- (annotation, type name, type vars, class name)
--
| DeriveClauseDeclaration SourceAnn (ProperName 'TypeName) [(Text, Maybe SourceType)] (Qualified (ProperName 'ClassName))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be tagged with a generated instance name? Both cases downstream are trying to generate instance names in two different ad hoc manners that aren't what's actually done in Convert.

Comment thread src/Language/PureScript/CST/Types.hs Outdated
Comment on lines +197 to +200
data DeriveClass a = DeriveClass
{ dcAnn :: a
, dcClass :: QualifiedName (N.ProperName 'N.ClassName)
} deriving (Show, Eq, Ord, Functor, Foldable, Traversable, Generic)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not totally sure why we need this, but the whole annotation thing in this CST is kind of bogus. I don't think the compiler uses annotations (the parser stashes () everywhere), it was mainly just a holdover from "maybe someone will need this" from when we tried to publish it as a stand alone library. There are no expected semantics, AFAIK. Honestly, I would just get rid of it and simplify this.

Just pair -> pair
Nothing -> internalError "Empty class name"
tyConArgs <- computeInstArgs ss mn tyName tyVars mClassData className
go $ TypeInstanceDeclaration sa sa chainId 0 (Left genName) [] className tyConArgs DerivedInstance
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both the Newtype/Generic machinery and typeclasses here are doing work that is otherwise done in Convert for normal deriving (ie generating all the bookkeeping for TypeInstanceDeclaration. Should this all the relevant data just be in Convert and added to DeriveClauseDeclaration?

data Either2 a b = Left2 a | Right2 b
derive (Bifunctor)

derive instance Eq a => Eq (Either2 a a)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably have a failure test for something like this being derived?

Convert now emits TypeInstanceDeclaration directly with the bare type
constructor as its single argument, matching what standalone
`derive instance` produces. The intermediate DeriveClauseDeclaration
node was constructed only to be rewritten in the desugarer, with the
desugarer re-deriving facts (instance name, chain id, type-arg arity)
that Convert already had.

The arity computation in Sugar/TypeClasses, which looked up the class
kind in the environment, also goes away. Kind matching is left to the
typechecker, exactly as for standalone derive. As a side effect, the
"can only derive upstream classes" limitation Nate flagged in review
is gone.

For Generic/Newtype attached deriving, binaryWildcardClass now accepts
the bare-`[T]` form by looking up the type's vars and padding with the
expected wildcard before falling into the existing handler.
Covers the case where the data type's kind doesn't fit the class —
e.g. `data Box a derive (Eq)` produces `Eq Box` and the typechecker
catches the mismatch (`Type -> Type` vs `Type`).
The CST annotation slot was always () — the parser stashed unit and
nothing downstream read it. Without the parameter, both types lose
their derived Functor/Foldable/Traversable too, which were equally
unused.
Mirrors Nate's reference to `derive instance Eq a => Eq (Either2 a a)`
on the standalone side: in attached form, `data Either2 a b derive (Eq)`
emits `Eq Either2` and the typechecker rejects the kind mismatch
(`Type -> Type -> Type` vs `Type`).
@i-am-the-slime i-am-the-slime force-pushed the attached-derive-clauses branch 2 times, most recently from 412d159 to b3ddaa9 Compare May 10, 2026 19:32
@i-am-the-slime
Copy link
Copy Markdown
Contributor Author

Relying on the kinds in the environment works for these hard coded use cases, but I'm not sure how that works for subsequent features. It essentially means you can only derive things with this syntax as long as the class you want to derive is in an upstream module since this all happens during desugaring. This might be problematic for the more complicated extensions like newtype/via as you might want to derive a class in the same module as where you define it. Not necessarily a deal breaker for this as-is, but it seems like a big open question.

Yes, I did that on purpose, I think since I assumed we'd go with:

newtype MyThing = MyThing String
   derive (Eq, Ord)
   derive newtype (Monoid)
   derive (DecodeJSON) via (NonEmptyString)

But maybe that's a bad idea.

The earlier switch from --ghc-options -Werror to --haddock-arguments
--optghc=-Werror broke CI on every push since 2026-04-25: it escalates
warnings in transitive dependency Haddock builds (conduit, aeson,
css-text, serialise, ...) to errors. The original comment warned
against exactly this. Restore the local-packages-only -Werror.
Comment thread src/Language/PureScript/CST/Convert.hs Outdated
Comment thread tests/purs/passing/DerivingClause.purs
Address PR review feedback:
- Rename drvs to deriveClauses in CST.Convert for readability
- Add failing test for combining derive (Newtype) with derive instance
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Much shorter instancing/generics-deriving syntax

3 participants