openlang

Validating JSON before the discriminated union

A type assertion only tells the compiler what to believe. An external response starts as bytes and says nothing about its variant until something checks it.

Text decoding and JSON parsing come first, each with its own failure path. The parsed value should reach validation as unknown.

The supported response is either a ready tag with an items array of strings or a failed tag with a message string. The runtime check has to confirm three things. The value is an object, its tag is one we know, and the payload for that tag has the required shape. An object with ready and no items is incomplete. One with waiting carries a tag outside the union. Writing as with the union fixes neither.

Once the check passes, the decoder returns a discriminated union. Testing for ready narrows it, and the items become usable. An exhaustive check with never points out an unhandled alternative in the declared model. Useful for maintenance, but it runs on types, not on response bytes.

Rejected input still needs a decision. Report a decoding failure, or model unknown responses as a variant on purpose. Casting straight into the accepted union skips that choice.

Keep the runtime evidence right next to where the typed value gets built.

后续讨论 9

顺着彼此的依据读下去
  1. 固定链接

    Haskell has a lazier version of this problem. An Either describes which forms a result can take. It says nothing about whether evaluation ever produces one.

    A function that matches Right _ and returns True accepts Right undefined without touching the payload. Ask for the length of that payload and evaluation does need it, and fails right there.

    So under your split between checking input and representing alternatives, Haskell has one more layer. The decoder can be right and the type precise, and a caller can still demand a value that isn't there.

  2. 固定链接

    Add Deferred(ticket) as a third case and follow where the change spreads. A closed Scala 3 enum gives the case its own data, which is nice, but now the decoder changes and so does every consumer, rendering and persistence included. That coordination pays off while one model owns all the alternatives.

    It turns into a cost once independent producers need to add variants without touching that central definition. Then an open interface, or an unknown envelope that gets explicitly validated, may fit better. You give up some central exhaustiveness for it.

  3. 固定链接

    A missing ticket can be read two ways, and the type won't pick one for you. A missing or null ticket could be a malformed response. It could also be an accepted deferral that just has no tracking reference. Typing it ticket?: string would at least make the absence visible, but it still doesn't say which of the two it is. Leave the field optional and every consumer of Deferred(ticket) ends up guessing. I'd settle it in the decoder before the case goes in.

  4. 固定链接

    Malformed, for this extension. deferred needs a ticket that is present and holds a nonempty string. A missing property fails decoding, so does null, so does "". That's the acceptance rule, and it lives in the decoder, right next to the tag check.

    The empty case people usually want already has a home. ready with items equal to [] means the request completed with nothing in it. That's a real result. A deferral with no ticket is a different thing and shouldn't borrow that shape.

    If the service later allows untracked deferral, that's a new alternative with its own name and its own note for callers. What I don't want is Deferred assembled out of absence just because the field happened to be optional. While the protocol is still moving, a separate variant is cheap to add and cheap to drop.

  5. 固定链接

    Maybe the rejected tickets don't need to reach the union at all. If the decoder returns Result<Response, ParseError>, they stay on the error side, and the caller can report a decoding error without building Deferred or an empty Ready. The decoder still does all the validating. The result type just gives the failure somewhere to go.

  6. 固定链接

    Kit, the trade you describe is the expression problem in a small form. With a closed enum, a new consumer is cheap and a new case touches every consumer. With an open interface it's reversed. A new case is one more implementation, and a new operation touches all of them.

    In Haskell the closed side is a data declaration such as data Response = Ready [Text] | Failed Text, and the open side is a type class. Your unknown envelope sits between the two. Add Unknown Value as a case and consumers still get exhaustiveness over the forms they know, while unexpected producers land somewhere explicit. If every caller treats Unknown the same as Failed, though, the extra case has little value.

  7. 固定链接

    Yes, with one change. ParseError as a single string makes every rejection look alike, and they aren't. An unknown tag usually means the service has grown a variant the client doesn't know yet. A ready with no items means somebody sent a broken response. I'd want to count those apart.

    type ParseError =
      | { kind: 'not-object' }
      | { kind: 'unknown-tag'; tag: string }
      | { kind: 'bad-payload'; tag: 'ready' | 'failed' | 'deferred'; field: string };

    The caller still gets one error side to handle, and logging can group by kind without parsing message text. It also answers hollis's question in the right place, a deferred response without a ticket comes back as bad-payload with field: 'ticket'.

  8. 固定链接

    If these responses get stored, the same rule belongs in the table, and the obvious spelling of it has a hole. In PostgreSQL a check constraint passes when its expression is true or null.

    CREATE TABLE response (
      status text NOT NULL,
      ticket text,
      CHECK (status <> 'deferred' OR ticket <> '')
    );
    
    INSERT INTO response VALUES ('deferred', NULL);  -- accepted

    For that row status <> 'deferred' is false and ticket <> '' is null, so the whole expression is null and the insert goes through. The '' case is caught, the missing ticket is not. Writing ticket IS NOT NULL AND ticket <> '' in the second branch closes it. The PostgreSQL documentation states the null rule outright, and SQLite behaved the same way in a quick run.

  9. 固定链接

    Go's encoding/json lines up with this rule better than I expected. Decode ticket into a *string and the four inputs land like this.

    type deferred struct {
    	Tag    string  `json:"tag"`
    	Ticket *string `json:"ticket"`
    }
    
    func ticketOK(raw []byte) bool {
    	var d deferred
    	err := json.Unmarshal(raw, &d)
    	return err == nil && d.Ticket != nil && *d.Ticket != ""
    }

    Missing field, nil. null, nil. "", a pointer to an empty string. "T-7", accepted. The nil check rejects the first two and the comparison rejects the third. With a plain string field, missing, null and "" all come out as the same empty value, and the decoder can no longer tell which one arrived. That's fine until the error message is supposed to say which one it was.

同一个问题的其他角度

  1. Annotation or satisfies for a config object

    For config objects I've mostly moved from const config: Config = {...} to {...} satisfies Config. With the annotation, config…

    回复 6最近活动 typescript
  2. Does an empty palette need its own case?

    An empty color list means two different things in a swatch editor, and Save should be able to tell them apart. Color validity…

    回复 8最近活动 fsharphaskell
  3. Is a read-only List<Int> a snapshot?

    List<Int> is a read-only view, not a snapshot. It tells a consumer which operations it can call through that reference. It pr…

    回复 4最近活动 kotlin