Lezione 2 di 6 · 25 min di lettura

Dati senza tipo

Quando i dati arrivano da fuori, il compilatore non sa cosa contengono. Il tipo Dynamic, i decoder di gleam/dynamic/decode, i record decodificati campo per campo, e gli errori che dicono dove guardare.

Non fidarsi, controllare

Nella lezione precedente il compilatore si fidava della firma di una funzione esterna, e una firma sbagliata faceva schiantare il programma. Il problema è più generale: ogni volta che un dato arriva da fuori, da una funzione Erlang, da un file, da una risposta di un server, il compilatore non può sapere com’è fatto. Può essere quello che ti aspetti, o no.

Gleam ha un modo sicuro per gestire questi dati: invece di fingere di conoscerne il tipo, li dichiari di tipo Dynamic, “qualsiasi cosa”. Poi, prima di usarli, li controlli con un decoder, che restituisce un Result: Ok con un valore Gleam ben tipato, oppure Error con la descrizione di cosa non andava. Da lì in poi, sei di nuovo nel mondo sicuro dei tipi.

Il tipo Dynamic

Dynamic sta nel modulo gleam/dynamic della libreria standard. Un valore Dynamic può essere un intero, una stringa, una lista, un dizionario: Gleam non lo sa, e non ti lascia usarlo direttamente. Non puoi sommarlo, né stamparlo con io.println.

Per fare esperimenti, il modulo ha delle funzioni che trasformano un valore normale in Dynamic: dynamic.int(42), dynamic.string("Ada"), dynamic.list([...]), e dynamic.properties, che costruisce un dizionario. Nei programmi veri i Dynamic arrivano da fuori; qui ce li fabbrichiamo da soli.

I primi decoder

I decoder stanno nel modulo gleam/dynamic/decode. Per ogni tipo di base ce n’è uno già pronto: decode.int, decode.float, decode.string, decode.bool. E decode.run applica un decoder a un dato:

src/first_decoder.gleam
import gleam/dynamic
import gleam/dynamic/decode
import gleam/io
import gleam/string

pub fn main() -> Nil {
  let data = dynamic.int(42)
  io.println(string.inspect(decode.run(data, decode.int)))
  io.println(string.inspect(decode.run(data, decode.string)))
  io.println(dynamic.classify(data))
}
output
Ok(42)
Error([DecodeError("String", "Int", [])])
Int

Il primo run chiede “è un intero?”, e la risposta è Ok(42): un Int vero, da usare come vuoi. Il secondo chiede “è una stringa?”, e riceve un errore. Un errore di decodifica è un record DecodeError con tre campi: expected (cosa si aspettava, "String"), found (cosa ha trovato, "Int"), e path, il percorso dentro il dato, che qui è vuoto. decode.run restituisce una lista di errori, come vedremo tra poco.

dynamic.classify dice che tipo di dato c’è dentro un Dynamic: comodo per i messaggi di errore.

Per le liste c’è decode.list, che riceve il decoder degli elementi: decode.list(decode.string) decodifica una List(String), e fallisce se anche un solo elemento non è una stringa.

Decodificare un record

I dati che arrivano da fuori sono quasi sempre strutturati: un utente con un nome, un’età, una lista di linguaggi. Per trasformarli in un record Gleam si costruisce un decoder campo per campo, con use e decode.field:

gleam
pub type Person {
  Person(name: String, age: Int, languages: List(String))
}

fn person_decoder() -> decode.Decoder(Person) {
  use name <- decode.field("name", decode.string)
  use age <- decode.field("age", decode.int)
  use languages <- decode.field("languages", decode.list(decode.string))
  decode.success(Person(name:, age:, languages:))
}

Si legge quasi come una frase: “prendi il campo name e decodificalo come stringa; prendi il campo age come intero; prendi languages come lista di stringhe; se tutto va bene, il risultato è un Person“. decode.success è il decoder che riesce sempre, con il valore che gli dai: l’ultimo passo, quello che mette insieme i pezzi.

Il use è quello della lezione 5.3: ogni riga passa il resto della funzione a decode.field come callback. Il risultato di person_decoder() non è un Person: è un decoder di Person, di tipo decode.Decoder(Person), una ricetta pronta da applicare a qualsiasi dato con decode.run.

Dove sbaglia, e quante volte

Proviamo il decoder su un dato giusto e su due sbagliati:

src/person_decoder.gleam
import gleam/dynamic
import gleam/dynamic/decode
import gleam/io
import gleam/string

pub type Person {
  Person(name: String, age: Int, languages: List(String))
}

fn person_decoder() -> decode.Decoder(Person) {
  use name <- decode.field("name", decode.string)
  use age <- decode.field("age", decode.int)
  use languages <- decode.field("languages", decode.list(decode.string))
  decode.success(Person(name:, age:, languages:))
}

pub fn main() -> Nil {
  let ada =
    dynamic.properties([
      #(dynamic.string("name"), dynamic.string("Ada")),
      #(dynamic.string("age"), dynamic.int(36)),
      #(
        dynamic.string("languages"),
        dynamic.list([dynamic.string("Gleam"), dynamic.string("Erlang")]),
      ),
    ])
  let joe =
    dynamic.properties([
      #(dynamic.string("name"), dynamic.string("Joe")),
      #(dynamic.string("age"), dynamic.string("forty")),
      #(dynamic.string("languages"), dynamic.list([dynamic.int(1)])),
    ])
  let nobody = dynamic.properties([])
  io.println(string.inspect(decode.run(ada, person_decoder())))
  io.println(string.inspect(decode.run(joe, person_decoder())))
  io.println(string.inspect(decode.run(nobody, person_decoder())))
}
output
Ok(Person("Ada", 36, ["Gleam", "Erlang"]))
Error([DecodeError("Int", "String", ["age"]), DecodeError("String", "Int", ["languages", "0"])])
Error([DecodeError("Field", "Nothing", ["name"]), DecodeError("Field", "Nothing", ["age"]), DecodeError("Field", "Nothing", ["languages"])])

Tre cose da notare:

  • Il decoder non si ferma al primo errore: nei dati di Joe segnala sia l’età sia la lista. Se stai correggendo un file, è molto meglio sapere subito tutto quello che non va.
  • Il path dice dove guardare: ["age"] è il campo age; ["languages", "0"] è il primo elemento (indice 0) della lista languages.
  • Un campo che manca del tutto dà expected: "Field" e found: "Nothing".

Quiz

Cosa restituisce decode.run(dynamic.string("7"), decode.int)?

Campi facoltativi

Non sempre un campo c’è. Se è facoltativo, decode.optional_field riceve anche un valore di riserva, da usare quando il campo manca:

gleam
use email <- decode.optional_field("email", "", decode.string)

Se email c’è, dev’essere una stringa (altrimenti è un errore); se non c’è, il valore è "". Se invece vuoi distinguere “assente” da “presente”, usa Option: decode.optional_field("email", None, decode.optional(decode.string)).

Dal mondo esterno

Adesso mettiamo insieme le due lezioni. Ecco una funzione Erlang che restituisce un profilo, come farebbe una libreria che legge un file di configurazione scritto a mano da qualcuno, quindi senza garanzie sul contenuto: un map di Erlang, cioè un dizionario, con chiavi e valori binari (le stringhe di Gleam, che in Erlang si scrivono <<"name">>):

src/profile_ffi.erl
-module(profile_ffi).
-export([load/0]).

load() ->
    #{<<"name">> => <<"Ada">>,
      <<"age">> => 36,
      <<"languages">> => [<<"Gleam">>, <<"Erlang">>]}.

La funzione esterna, questa volta, non finge di conoscere la forma dei dati: restituisce Dynamic. E prima di usare il profilo, lo decodifichiamo:

src/profile.gleam
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/io
import gleam/string

pub type Profile {
  Profile(name: String, age: Int, languages: List(String), email: String)
}

@external(erlang, "profile_ffi", "load")
fn load_profile() -> Dynamic

fn profile_decoder() -> decode.Decoder(Profile) {
  use name <- decode.field("name", decode.string)
  use age <- decode.field("age", decode.int)
  use languages <- decode.field("languages", decode.list(decode.string))
  use email <- decode.optional_field("email", "", decode.string)
  decode.success(Profile(name:, age:, languages:, email:))
}

pub fn main() -> Nil {
  case decode.run(load_profile(), profile_decoder()) {
    Ok(profile) ->
      io.println(
        profile.name <> " knows " <> string.join(profile.languages, ", "),
      )
    Error(errors) -> io.println("Invalid profile: " <> string.inspect(errors))
  }
}
output
Ada knows Gleam, Erlang

Se un giorno il profilo cambiasse forma, il programma non si schianterebbe in un punto a caso: finirebbe nel ramo Error, con un messaggio che dice esattamente quale campo non va.

Esercizio · sul tuo computer

Il catalogo della biblioteca

Crea src/library.gleam, con un tipo Book(title: String, year: Int) e un book_decoder().

Costruisci con dynamic.properties due dati: il primo con title "Dune" e year 1965 (un intero), il secondo con lo stesso titolo ma year scritto come stringa, "1965". Decodificali entrambi, e stampa il risultato con una funzione describe:

  • se va bene, titolo e anno separati da una virgola;
  • se no, il primo errore della lista, nella forma Error at <percorso>: expected <atteso>, found <trovato>, con i pezzi del percorso uniti da un punto.
output
Dune, 1965
Error at year: expected Int, found String

Suggerimento: un pattern come Error([decode.DecodeError(expected:, found:, path:), ..]) prende il primo errore e ne estrae i campi.

Mostra una soluzione (prima prova da solo!)
src/library.gleam
import gleam/dynamic
import gleam/dynamic/decode
import gleam/int
import gleam/io
import gleam/string

pub type Book {
  Book(title: String, year: Int)
}

fn book_decoder() -> decode.Decoder(Book) {
  use title <- decode.field("title", decode.string)
  use year <- decode.field("year", decode.int)
  decode.success(Book(title:, year:))
}

pub fn main() -> Nil {
  let good =
    dynamic.properties([
      #(dynamic.string("title"), dynamic.string("Dune")),
      #(dynamic.string("year"), dynamic.int(1965)),
    ])
  let bad =
    dynamic.properties([
      #(dynamic.string("title"), dynamic.string("Dune")),
      #(dynamic.string("year"), dynamic.string("1965")),
    ])
  io.println(describe(decode.run(good, book_decoder())))
  io.println(describe(decode.run(bad, book_decoder())))
}

fn describe(result: Result(Book, List(decode.DecodeError))) -> String {
  case result {
    Ok(book) -> book.title <> ", " <> int.to_string(book.year)
    Error([decode.DecodeError(expected:, found:, path:), ..]) ->
      "Error at "
      <> string.join(path, ".")
      <> ": expected "
      <> expected
      <> ", found "
      <> found
    Error([]) -> "Unknown error"
  }
}

Il ramo Error([]) non capiterà mai (un decoder che fallisce dà sempre almeno un errore), ma il compilatore non lo sa, e vuole che tutti i casi siano coperti.

Ricapitolando

  • I dati che arrivano da fuori hanno tipo Dynamic: Gleam non ti lascia usarli finché non li controlli.
  • Un decoder (gleam/dynamic/decode) controlla un Dynamic e restituisce Ok(valore) o Error(lista di DecodeError); si applica con decode.run(dato, decoder).
  • Decoder pronti: decode.int, decode.float, decode.string, decode.bool, decode.list(inner).
  • Un record si decodifica campo per campo: use x <- decode.field("x", decoder), e alla fine decode.success(Record(...)).
  • Il decoder raccoglie tutti gli errori; path dice dove guardare.
  • decode.optional_field(chiave, riserva, decoder) per i campi che possono mancare.
  • Dynamic solo per dati di forma davvero sconosciuta, seguito subito da un decoder; per il resto, tipi precisi.

Nella prossima lezione, la fonte di dati esterni più comune di tutte: il JSON.