JavaRush /Courses /Go SELF /Helper parseID: contract, errors, and ValidationError.Fie...

Helper parseID: contract, errors, and ValidationError.Fields

Go SELF
Level 60, Lesson 3
Available

1. parseID: why it exists and what its contract is

When you first write a handler like GET /api/v1/tasks/{id}, everything looks innocent: you took id from the path, parsed it, and moved on. But once you add a second handler (DELETE /api/v1/tasks/{id}), then a third (POST /api/v1/tasks/{id}/done)—you suddenly discover that each one repeats the exact same logic. And then you get “almost the same” logic, just with different error texts—and a small local hell begins.

Why copy-pasting “Atoi + checks” is a trap

Let’s imagine a typical “first version” that beginners run into (and almost all of us wrote on day one):


idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
    http.Error(w, "bad id", http.StatusBadRequest)
    return
}
if id <= 0 {
    http.Error(w, "bad id", http.StatusBadRequest)
    return
}

It works, but the problem is that you mixed three different responsibilities in one place: extracting input, validation rules, and forming the HTTP response. As a result, any “cosmetic” update turns into a mass operation: you changed a rule (id can be 0 now? or not?), changed an error message, wanted to return fields in JSON—and now you edit 5–10 handlers.

That’s why we introduce a small but very useful helper: parseID. It does one thing: it takes a string and returns (int, error) under a single contract. And the handler decides how exactly to turn the error into an HTTP response.

The parseID contract: what the function promises the caller

When we say “function contract”, it’s not a legal document (though sometimes you want to invite a lawyer to a code review). It’s a clear agreement: what inputs we expect, what outputs we return, and how to interpret an error. If the contract is fuzzy, each handler starts “inventing” rules on its own—and you get copy-paste again, just more creative.

For parseID, let’s lock down a simple contract:

Input (string) What it means Result
"123"
valid id
id = 123, err = nil
""
id is missing / not extracted
id = 0, err = ValidationError
"abc"
not a number
id = 0, err = ValidationError
"-5" or "0" a number, but it does not meet the rules
id = 0, err = ValidationError

The key validation rules today are minimal and practical:

  1. id must be an integer (via strconv.Atoi)
  2. id must be positive (id > 0)

Why exactly id > 0? Because in many APIs, id is a record identifier in storage, and 0 usually means “not set”. You can make a different agreement, but what matters more is: it must be one rule and the same everywhere.

I’ll emphasize it in a separate line, because this is an important principle: parseID does not write an HTTP response. It doesn’t know what your error format is (a JSON envelope or plain text), and it shouldn’t. It simply returns an error value—in the Go spirit.

ValidationError: one error, but with per-field details

If you build an API even a bit more carefully than “return a string and good luck”, you will quickly need to return validation details: what exactly is wrong. In JSON APIs this usually looks like “error code + message + field errors”. We want the client to receive something like:

{
  "error": {
    "code": "validation",
    "message": "invalid request",
    "fields": {
      "id": "must be integer"
    }
  }
}

To avoid hand-crafting this in each handler, we introduce a typed error:

package main

type ValidationError struct {
    Fields map[string]string
}

func (e *ValidationError) Error() string {
    return "validation error"
}

There are two important “whys” here.

First: Fields is a machine-readable structure. It’s not only for humans, but also for the client (for example, a UI can highlight the "id" field in red and show the text).

Second: Error() returns a short generic text. It doesn’t have to include all details, because the details already live in Fields. That’s both more convenient and cleaner.

Yes, we could return just fmt.Errorf("id must be integer"). But then your general error handler wouldn’t be able to fill fields in the error envelope structurally. You’d end up back at “parsing error strings”, which is about as respectable as parsing HTML with regexes: it sometimes works, but it’s embarrassing.

Implementation of parseID: short and with unified messages

The implementation of parseID should be as boring as possible. That’s a compliment: the more boring this code is, the less it breaks. We simply check the input step by step, returning ValidationError in a single format.

package main

import (
    "strconv"
    "strings"
)

func parseID(s string) (int, error) {
    s = strings.TrimSpace(s)
    if s == "" {
        return 0, &ValidationError{Fields: map[string]string{"id": "is required"}}
    }

    id, err := strconv.Atoi(s)
    if err != nil {
        return 0, &ValidationError{Fields: map[string]string{"id": "must be integer"}}
    }
    if id <= 0 {
        return 0, &ValidationError{Fields: map[string]string{"id": "must be positive"}}
    }

    return id, nil
}

A couple of practical notes about this code.

We do strings.TrimSpace because input from a URL is usually “clean”, but life loves surprises. Let "/tasks/ 12 " not become your personal horror movie.

We return 0 together with the error. That’s fine: by contract, id is valid only if err == nil. 0 here is just a “placeholder”.

And the most important part: all handlers will now receive the same validation error and can convert it into the same API response.

2. Handler and error envelope: how to pass Fields into the response

Handlers in Go easily turn into mush if you do parsing, validation, business logic, and responses all at once. We want the handler to read like a linear script: “read input → validate → perform action → respond”.

How parseID is used in a handler

Here is an example handler that extracts {id}, validates it via parseID, and then could go to storage (today we don’t care about storage; we care about the input contract).

package main

import (
    "errors"
    "net/http"
)

func handleGetTask(w http.ResponseWriter, r *http.Request) {
    id, err := parseID(r.PathValue("id"))
    if err != nil {
        var ve *ValidationError
        if errors.As(err, &ve) {
            writeValidationError(w, ve.Fields)
            return
        }
        writeInternalError(w)
        return
    }

    _ = id // later we will load a task by id here
    w.WriteHeader(http.StatusOK)
}

Pay attention to errors.As. This is the standard way to “extract” an error of a specific type from a value of type error. In Go 1.13, standard mechanisms errors.Is/errors.As appeared to make such checks uniform.

The point is that the handler doesn’t have to know how the error is implemented inside (string vs struct). It does a simple thing: if this is a ValidationError, return 400 with fields. Otherwise, return 500, but without leaking details to the outside.

How ValidationError.Fields ends up in the error envelope

A unified JSON error format is often called an “error envelope”: we always respond with an object like { "error": { ... } }, even if the error inside is different. It’s easier for the client: it knows where to look and doesn’t have to write 15 branches to parse responses.

First, let’s define the structures (the minimal ones we need):

package main

type APIError struct {
    Code    string            `json:"code"`
    Message string            `json:"message"`
    Fields  map[string]string `json:"fields,omitempty"`
}

type ErrorEnvelope struct {
    Error APIError `json:"error"`
}

omitempty here means: if Fields == nil, then the fields property will not appear in JSON. This is useful because validation errors really are different from the rest: only they need per-field errors.

Now let’s make a small writer for validation errors. Note: we don’t have to build a perfect universal system today; we just need to see the principle “Fields → fields”.

package main

import (
    "encoding/json"
    "net/http"
)

func writeValidationError(w http.ResponseWriter, fields map[string]string) {
    env := ErrorEnvelope{Error: APIError{
        Code: "validation", Message: "invalid request", Fields: fields,
    }}

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(http.StatusBadRequest)
    _ = json.NewEncoder(w).Encode(env)
}

And for an internal error—a separate safe response. The main rule: for 5xx, do not return to the client err.Error(), because it can contain anything: file paths, DB details, internal table names—and just embarrassing messages you wrote at 3 a.m.

package main

import (
    "encoding/json"
    "net/http"
)

func writeInternalError(w http.ResponseWriter) {
    env := ErrorEnvelope{Error: APIError{Code: "internal", Message: "internal error"}}

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(http.StatusInternalServerError)
    _ = json.NewEncoder(w).Encode(env)
}

If you’re thinking: “Why not make an appHandler that returns an error and one common ServeHTTP so we don’t repeat handling?”—you’re thinking in the right direction. In Go, this is a popular technique to reduce repetition of error handling in HTTP code. But today our goal is already achieved: we learned how to return validation errors consistently and put details into fields.

To reinforce it, here’s a small flow diagram:

flowchart TD
    A["HTTP request /api/v1/tasks/{id}"] --> B[handler]
    B --> C["r.PathValue('id') -> string"]
    C --> D[parseID]
    D -->|id, nil| E[successful logic]
    D -->|ValidationError| F[writeValidationError -> 400 + fields]
    D -->|other error| G[writeInternalError -> 500]

Mini build: what an app with parseID looks like

To give you the feeling of a complete picture, let’s assemble a minimal main that registers a route with {id} and uses the handler above. The code is intentionally simple: no task storage, no JSON task output—only checking id and status codes.

package main

import "net/http"

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/v1/tasks/{id}", handleGetTask)

    _ = http.ListenAndServe(":8080", mux)
}

Now mentally (or actually via curl) we test:

If the request is GET /api/v1/tasks/abc, then parseID will return a ValidationError with {"id":"must be integer"}, and the API will respond with 400 and put it into error.error.fields.id.

If the request is GET /api/v1/tasks/0, then it will be {"id":"must be positive"}.

If the request is GET /api/v1/tasks/10, then everything is OK and it will be 200 OK (for now, with no body).

3. Typical mistakes when designing parseID and ValidationError

Mistake #1: mixing responsibility levels.
If parseID returns a ValidationError in one place, fmt.Errorf in another, and in a third place writes the HTTP response itself, the contract becomes blurry. The handler stops understanding what exactly it gets and how to handle it. As a result, the logic spreads across the code again. parseID should either return a strictly defined error type, or a value and an error—but it should not do HTTP and should not “jump” between formats.

Mistake #2: ignoring the error from strconv.Atoi.
Atoi does not try to “guess” what you meant. If the string is not a number, it returns an error—and you must check it. If you don’t, the code will proceed with an invalid id (for example, 0), and the problem will surface later and somewhere else. Error handling should be immediate and explicit.

Mistake #3: not accounting for an empty id.
Even if the /tasks/{id} route implies that the segment exists, an empty string can appear due to a typo in the parameter name (for example, {taskID}, but you read PathValue("id")). Therefore, parseID must explicitly check for an empty value and return a predictable "is required" error so that the failure shows up immediately and correctly returns 400.

Mistake #4: returning err.Error() to the client in a 500 response.
It’s convenient for debugging, but risky for production. The error text may contain internal paths, infrastructure details, or technical messages you must not expose publicly. The public message should be stable and safe, while the details should stay in server logs.

1
Task
Go SELF, level 60, lesson 3
Locked
Form error
Form error
1
Task
Go SELF, level 60, lesson 3
Locked
ID checkpoint
ID checkpoint
1
Task
Go SELF, level 60, lesson 3
Locked
ID in a Range
ID in a Range
1
Task
Go SELF, level 60, lesson 3
Locked
Tasks API
Tasks API
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION