Go linter that enforces samber/mo: absence is mo.Option, not a nil pointer or a trailing bool.
[!NOTE]
The rules are strict on purpose. They are meant for applications that choose samber/mo, not for libraries.
Overview
func Find(name string) *User {
u, ok := users[name]
if !ok {
return nil
}
return u
}
func Lookup(name string) (*User, bool) {
u, ok := users[name]
return u, ok
}
func Guest(o mo.Option[*User]) *User {
return o.OrEmpty()
}
$ molint ./...
user.go:12:3: Find returns a nil *User; return mo.Option[*User] instead [return-nil]
user.go:17:6: Lookup reports absence with a trailing bool; return mo.Option[*User] instead [return-bool]
user.go:23:18: OrEmpty on mo.Option[*User] gives nil when it is empty; use Get and check ok, or OrElse with a non-nil value [unwrap-nil]
Every report ends with the name of its rule. That name is what //molint:ignore takes.
molint reads the shape of signatures, and follows values only inside one function. It does not look for nil panics. Use it beside uber-go/nilaway:
| Tool |
Job |
Reads |
| uber-go/nilaway |
Finds possible nil panics |
The flow of values across functions |
| molint |
Enforces the use of mo.Option and mo.Result |
The shape of signatures and code |
Install
| Method |
Command |
Needs |
| mise (recommended) |
mise use "github:mpyw/molint@0.3.0" |
Nothing. Installs the prebuilt binary |
go tool |
go get -tool github.com/mpyw/molint/cmd/molint@latest |
Go 1.27+ |
go install |
go install github.com/mpyw/molint/cmd/molint@latest |
Go 1.27+ |
| Release archive |
See below |
Nothing |
molint ./...
molint -V=full # the release this binary was built from
Pin a version, run through go vet or golangci-lint, or install from an archive
mise use pins the version in the project's mise.toml, so every checkout and CI run the same one. Add -g to install it for every project on your machine instead.
[tools]
"github:mpyw/molint" = "0.3.0"
As a tool dependency in go.mod:
go get -tool github.com/mpyw/molint/cmd/molint@latest
go tool molint ./...
Through go vet, which runs it with the same package loading as the rest of your vet checks, and caches it:
go vet -vettool=$(which molint) ./...
Without installing anything:
go run github.com/mpyw/molint/cmd/molint@latest ./...
From a release archive, verified against the published checksums:
VERSION=0.3.0
curl -LO "https://github.com/mpyw/molint/releases/download/v${VERSION}/molint_${VERSION}_darwin_arm64.tar.gz"
curl -LO "https://github.com/mpyw/molint/releases/download/v${VERSION}/checksums.txt"
shasum -a 256 -c checksums.txt --ignore-missing
tar xzf "molint_${VERSION}_darwin_arm64.tar.gz"
Inside golangci-lint, as a module plugin. molint is not bundled with golangci-lint, so build a binary that holds it. Write .custom-gcl.yml:
version: v2.13.1 # the golangci-lint release to build
plugins:
- module: github.com/mpyw/molint
import: github.com/mpyw/molint/plugin
version: v0.3.0
Turn it on in .golangci.yml. Each key under settings is a rule's name, as the flags take it. A rule left out keeps its default. An unknown name stops the run:
version: "2"
linters:
enable:
- molint
settings:
custom:
molint:
type: module
description: Enforces samber/mo.
settings:
field-nil-compare: true
Then build and run it:
golangci-lint custom # writes ./custom-gcl
./custom-gcl run ./...
Rules
| Rule |
Default |
Reports |
return-nil |
π’ On |
A nil pointer result β Use mo.Option instead |
return-bool |
π’ On |
A signature that ends in a bool after other results β Use mo.Option instead |
return-error |
π΄ Off |
A signature that ends in an error after other results β Use mo.Result instead |
field-nil-store |
π’ On |
nil stored into a pointer field β Make the field mo.Option |
field-nil-compare |
π΄ Off |
A pointer field compared with nil β Make the field mo.Option |
wrap-nil |
π’ On |
nil given to mo.Some, mo.Ok, or mo.Err β Use mo.None, or pass a non-nil value |
result-zero |
π’ On |
A zero mo.Result β Build it with mo.Ok or mo.Err |
unwrap-nil |
π’ On |
OrEmpty, or OrElse(nil), where the nil breaks on use: pointer, interface, map, func or chan β Use Get and check ok, or give OrElse a non-nil value |
unwrap-discard |
π’ On |
Get with its ok or its error discarded β Check it, or use OrElse |
The line between them: absence must not be dropped silently. A trailing bool and a discarded ok drop it silently, whatever the type. OrEmpty and OrElse choose a default in plain sight. That is fine, unless the default is nil.
Each rule has a flag of its name:
molint -return-error -field-nil-compare ./... # every rule
molint -return-bool=false ./... # every rule on by default, except return-bool
Nothing is reported in a generated file. Test files are checked like any other file.
return-nil
A return must not give a nil pointer. Only pointers are checked: a slice, a map, a func, a channel or an interface may still be returned as nil.
| Code | Valid? | Reason |
func F() *T {
return nil
}
|
β |
The result is a nil pointer |
func F() *T {
var p *T
return p
}
|
β |
p is never set, so it is nil |
func F() (*T, error) {
return nil, nil
}
|
β |
Both the pointer and the error are nil |
func F() (*T, error) {
return nil, ErrNotFound
}
|
β
|
The error is not nil |
func F() (*T, bool) {
return nil, false
}
|
β οΈ |
Not reported by this rule. return-bool reports the signature |
func F() (*T, bool) {
return nil, true
}
|
β |
ok says the value is there, but it is nil |
func F() (T, bool) {
return T{}, false
}
|
β οΈ |
Not a pointer, so not reported by this rule. return-bool reports the signature |
func F() (T, error) {
return T{}, nil
}
|
β
|
Not a pointer, so this rule does not apply |
func F() []T {
return nil
}
|
β
|
A nil slice is Go's empty slice, and nothing claims that a value is present |
A nil is followed through branches and loops in the function. A nil check on the way stops it:
var p *T
if c {
p = get()
}
if p == nil {
p = def
}
return p // not reported
A function literal is exempt. So is a method that implements an interface, since the interface fixes its signature.
Fix
func F() mo.Option[*T] {
if !found {
return mo.None[*T]()
}
return mo.Some(&T{})
}
return-bool
A signature must not end in a bool after at least one other result.
| Signature | Valid? | Reason |
func Find() (User, bool)
|
β |
Absence is a trailing bool, whatever type comes before it |
func Cut() (string, string, bool)
|
β |
Absence is a trailing bool, after several values |
func IsAdmin() bool
|
β
|
Nothing comes before the bool |
Functions, methods, methods of named interfaces, and named function types are checked. A function literal is exempt. So is a method that implements an interface: the interface's own declaration is reported instead, when it is in the package.
Fix
func Find() mo.Option[User]
func Cut() mo.Option[lo.Tuple2[string, string]]
For several values, use a struct, or a tuple of samber/lo such as lo.Tuple2.
[!NOTE]
A method is exempt only when molint sees the interface. It must be declared in the package, or in a package that the package imports directly. MarshalJSON in a package that does not import encoding/json is not exempt.
return-error
The same as return-bool, with error in place of bool. It is off unless -return-error is set.
| Signature | Valid? | Reason |
func Find() (User, error)
|
β |
Failure is a trailing error |
func Load() (Config, Meta, error)
|
β |
Failure is a trailing error, after several values |
func Close() error
|
β
|
Nothing comes before the error |
Fix
func Find() mo.Result[User]
func Load() mo.Result[lo.Tuple2[Config, Meta]]
field-nil-store
A nil must not be stored into a pointer field.
[!IMPORTANT]
Run exhaustruct beside it. A field left out of a composite literal is not seen by molint. exhaustruct makes you write it, as in F: nil, and then molint reports it. Without exhaustruct, the rule still reports every nil that is written.
| Code | Valid? | Reason |
u := User{Name: n, Manager: nil}
|
β |
The field holds nil, so it may be absent |
u.Manager = nil
|
β |
The field holds nil, so it may be absent |
var m *User
if ok {
m = x
}
u.Manager = m
|
β |
m is nil when ok is false |
u.Manager = find(id)
|
β οΈ |
Not reported by this rule, since a call's result is not followed. If find returns nil, return-nil reports it there |
Only fields declared in the package count. An embedded field and a field in a generated file do not.
Fix
type User struct {
Name string
Manager mo.Option[*User]
}
u := User{Name: n, Manager: mo.None[*User]()}
[!NOTE]
A struct filled after it is built is reported too. u := User{Name: n, Manager: nil}; u.Manager = m reports the literal. Compute m first, then build User with it.
field-nil-compare
A pointer field must not be compared with nil. A nil check says the field may be absent. It is off unless -field-nil-compare is set.
| Code | Valid? | Reason |
if req.Name != nil {
user.Name = *req.Name
}
|
β |
The field may be absent |
switch u.Manager {
case nil:
return guest
}
|
β |
The field may be absent |
if c.client == nil {
c.client = dial()
}
|
β |
The field is absent until its first use |
if u.Manager == boss {
notify(u)
}
|
β
|
It is not compared with nil |
It does not need exhaustruct. It also finds a field that only a decoder such as json.Unmarshal leaves nil. The fields that count are the same as for field-nil-store.
Fix
type UpdateRequest struct {
Name mo.Option[string] `json:"name"`
}
if name, ok := req.Name.Get(); ok {
user.Name = name
}
For a field set on first use, use sync.OnceValue, or make it an mo.Option.
wrap-nil
| Call | Valid? | Reason |
mo.Some[*T](nil)
|
β |
The option is present and holds nil |
mo.Ok[*T](nil)
|
β |
The result is Ok and holds nil |
mo.Err[T](nil)
|
β |
The result is an error, and its error is nil |
mo.Some[map[string]int](nil)
|
β |
The option is present, and writing to its map panics. A nil func panics when called, and a nil channel blocks forever |
mo.Some[[]int](nil)
|
β |
Some claims that a value is present, but it holds nil. It then encodes in JSON as null, as mo.None does |
mo.Some([]int{})
|
β
|
An empty slice is not nil, and encodes as [] |
Fix
mo.None[*T]() // the value is absent
mo.Some(&T{}) // the value is there, and not nil
mo.Err[T](ErrNotFound) // a failure, with a non-nil error
result-zero
A zero mo.Result is Ok, holding the zero value of its type. It must not be used.
func Load() mo.Result[Config] {
var r mo.Result[Config]
return r // reported
}
A return, an argument, a store, a send, and a method call are uses. A comparison is not. A zero mo.Option is None, which is fine, so it is not reported.
Fix
func Load() mo.Result[Config] {
cfg, err := read()
if err != nil {
return mo.Err[Config](err)
}
return mo.Ok(cfg)
}
unwrap-nil
| Call | Valid? | Reason |
var o mo.Option[*User]
o.OrEmpty()
|
β |
It gives nil when the option is empty |
var o mo.Option[*User]
o.OrElse(nil)
|
β |
The fallback is nil |
var o mo.Option[*User]
o.OrElse(&guest)
|
β
|
The fallback is not nil |
var o mo.Option[*User]
o.MustGet()
|
β
|
It panics rather than give nil |
var n mo.Option[int]
n.OrEmpty()
|
β
|
The zero value works, and is chosen in plain sight |
var m mo.Option[map[string]int]
m.OrEmpty()
|
β |
It gives a nil map when the option is empty, and writing to it panics |
Fix
if u, ok := o.Get(); ok {
use(u)
}
// Or, with a fallback that is not nil:
u := o.OrElse(&guest)
unwrap-discard
| Code | Valid? | Reason |
v, _ := o.Get()
use(v)
|
β |
ok is discarded, whatever the type of v |
var r mo.Result[int]
v, _ := r.Get()
use(v)
|
β |
The error is discarded |
if v, ok := o.Get(); ok {
use(v)
}
|
β
|
ok is checked |
o.Get()
|
β
|
Nothing is used |
Fix
if v, ok := o.Get(); ok {
use(v)
}
// Or, for a mo.Result:
v, err := r.Get()
if err != nil {
return err
}
// Or, with a fallback:
v := o.OrElse(10)
Agent skill
skills/molint-authoring is a skill for an AI agent writing code under molint. It says how to fix each rule with samber/mo, which fixes only hide a problem, and when an ignore is right. The binary carries it:
molint skill install # the agents already set up in this project
molint skill install --agent claude-code --scope user
molint skill list # where it is, and whether it is current
Without the binary, gh skill install mpyw/molint molint-authoring --agent claude-code writes to the same directories. The installer is go-skill-embed.
Ignoring a report
Write //molint:ignore with the rules to silence and a reason after //.
//molint:ignore return-bool // callers rely on the comma-ok form
func Lookup(name string) (*User, bool) {
| Placement |
Silences |
| On a line of its own |
The line below |
| After code |
Its own line |
| Directive |
Result |
//molint:ignore return-nil, wrap-nil // reason |
Silences both rules |
//molint:ignore // reason |
Silences every rule |
| No reason |
Reported, and silences nothing |
| An unknown rule name |
Reported |
| An ignore that silences nothing |
Reported as unused, unless every rule it names is turned off |
Limits
These are not checked:
| Case |
Why |
| A nil from a parameter, a field, or a call |
Values are followed only inside one function. uber-go/nilaway follows them further |
mo.TupleToOption, mo.TupleToResult, mo.EmptyableToOption |
They check their arguments at run time |
A field left out of a composite literal, as in Holder{} |
SSA has no store for it. Run exhaustruct to make every field written |
A struct made zero by var s S, new(S), or a decoder |
Fields are not followed. field-nil-compare still finds a field checked for nil |
| A field of a struct declared in another package |
molint cannot tell whether that package's file is generated |
| The initializer of a package-level variable |
No rule reads the package's initializer |
| A constructor or method of mo passed as a function value |
Calls through function values are not followed |
These are reported although no run returns nil, since branches are taken as independent:
| Case |
Instead |
if c { p = x }; if c { return p } |
Keep the value and its condition together, as in mo.Option |
The pointer and the error set on separate branches, then if err != nil { return nil, err }; return u, nil |
Return from each branch |
A retry loop that ends with return nil, err |
Start the error at a sentinel, so that no round leaves it nil |
The full specification, with every limit, is design/rules.md.
License
MIT