danielgtaylor / danielgtaylor/huma

nullable:"true" on an object-ref field panics, so a struct pointer can never accept explicit `null`

Open
#1,098 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
4.4k
Forks
285
Avg merge
40m
Merged PRs (30d)
1

Description

## Summary

A `*Struct` body field cannot accept an explicit JSON `null` from the client. Sending `null` fails validation with `expected object`, and the tag that would express the intent — `nullable:"true"` — panics at registration time when the field's schema is an object `$ref`.

The only escape hatch is the ``_ struct{} `nullable:"true"` `` sentinel inside the referenced struct, which attaches nullability to the **type** even though nullability is a property of the **reference site**: every other field in every other schema that reuses that struct silently becomes nullable too.

`null` is the natural wire representation for "clear this optional sub-object" in a `PUT`, so this comes up on any resource with an optional owner/parent/address block.

## Reproduction

Reproduced against v2.39.0 (latest release is v2.39.1; the guard is unchanged on `main` at the time of writing).

```go
type Owner struct {
CustomerId uint64 `json:"customerId" required:"true"`
}

type UpdateBody struct {
DisplayName string `json:"displayName" required:"true"`
Owner *Owner `json:"owner"`
}

type UpdateInput struct{ Body UpdateBody }
```

`PUT` with `{"displayName": "x", "owner": null}`:

```json
{
"title": "Unprocessable Entity",
"status": 422,
"detail": "validation failed",
"errors": [
{ "message": "expected object", "location": "body.owner" }
]
}
```

Adding the tag that describes the intent:

```go
Owner *Owner `json:"owner" nullable:"true"`
```

panics during `huma.Register`:

```
nullable is not supported for field 'Owner' which is type '#/components/schemas/Owner'
```

Full runnable repro (two `humatest` tests, one asserting the 422 and one asserting the panic) available on request.

## Why the current behaviour surprises

`schema.go`:

```go
fieldRequired := !getConfig[registryConfig](r).FieldsOptionalByDefault
```

Every field is required by default, so a `*Struct` field with a plain `json:"owner"` tag lands in `requiredMap`. The validator then reaches:

```go
// validate.go
if m[k] == nil && (!s.requiredMap[k] || s.Nullable) {
// This is a non-required field which is null, or a nullable field set
// to null, so ignore it.
continue
}
```

`s` here is the **parent** object schema, so `s.Nullable` asks whether the enclosing object may be null — not whether property `k` may be. The comment ("a nullable field set to null") describes the property, so the lookup appears to be reading the wrong schema. It is not user-visible today, because a nullable property schema is caught later by `if s.Nullable && v == nil { return }` after the ref is resolved, but it is the first line anyone lands on when debugging this and it reads as if per-property nullability were already handled.

The real blocker is the guard in `SchemaFromField`:

```go
if fs.Nullable && fs.Ref != "" && registry.SchemaFromRef(fs.Ref).Type == "object" {
// Nullability is only supported for scalar types for now. Objects are
// much more complicated. ... needs to use `anyOf` or `not` which is not
// supported by all code generators ...
panic(...)
}
```

## Proposal

Let `nullable:"true"` on an object-ref field emit the OpenAPI 3.1 / JSON Schema 2020-12 idiom instead of panicking:

```json
"owner": {
"oneOf": [
{ "$ref": "#/components/schemas/Owner" },
{ "type": "null" }
]
}
```

- huma already validates `oneOf`, so the validator side needs little or no new machinery.
- Nullability stays at the reference site — reusing `Owner` in a non-nullable position is unaffected, which the `_ struct{}` sentinel cannot express.
- The 2024 code-generator concern in that comment has aged: current OpenAPI 3.1 generators (openapi-generator 7.x, oapi-codegen, orval) handle a `oneOf` with `{"type": "null"}`. If that is still a worry for some downstreams, it could ship behind a registry config flag (`AllowNullableObjects`) so existing specs are untouched by default.
- Related but distinct: #761 asks for general `oneOf`/`anyOf` struct tags; this is the narrow nullability case, which today has no non-global workaround.

Secondary, independent of the above: consider resolving the property schema in the `validate.go` check quoted earlier, so the condition means what its comment says.

## Workarounds today, for anyone hitting this

- `json:"owner" required:"false"` — the field leaves `requiredMap`, so the validator skips the null. Accepts `null` and an omitted key alike, and the published schema does not advertise `null`.
- ``_ struct{} `nullable:"true"` `` inside the referenced struct — schema correctly says `["object", "null"]`, but the nullability applies to every use of that struct.

## Environment

- huma `v2.39.0`
- Go 1.25

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in schema.go at SchemaFromField's nullable/ref/object guard, then trace validate.go's requiredMap and null check. Use the supplied Owner/UpdateBody humatest reproduction to cover registration, generated schema, and PUT validation. Done means nullable object refs accept explicit null at the reference site without making other Owner uses nullable, while existing non-nullable behavior remains unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
api
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.