Como Parse JSON em Dart/Flutter: O Guia Definitivo

Tempo de leitura: 18 minutes

Parsing JSON é uma tarefa muito comum para aplicativos que precisam buscar dados na Internet.

E dependendo da quantidade de dados JSON que você precisa processar, você tem duas opções:

  1. escreva todo o código de análise JSON manualmente
  2. automatizar o processo com geração de código

Este guia se concentrará em como analisar manualmente JSON para código Dart, incluindo:

  • codificação e decodificação JSON
  • definindo classes de modelo com segurança de tipo
  • parsing JSON para código Dart usando um construtor de fábrica
  • lidando com valores anuláveis e opcionais
  • validação de dados com correspondência de padrões (novo no Dart 3)
  • serializando dados de volta para JSON
  • como depurar nosso código quando a análise JSON falha
  • casos de uso avançados (JSON complexo/aninhado, análise condicional, etc.)
  • escolhendo valores profundos com o pacote deep_pick

Ao final, você saberá como escrever um código robusto de análise e validação JSON que pode ser usado na produção.

Temos muito o que abordar, então vamos começar do básico.

 

Anatomia de um documento JSON

Se você já trabalhou com alguma API REST antes, este exemplo de resposta JSON deve parecer familiar:

{
  "name": "Pizza do Mario",
  "cuisine": "Italiana",
  "reviews": [
    {
      "score": 4.5,
      "review": "A pizza foi incrível!"
    },
    {
      "score": 5.0,
      "review": "Pessoal muito simpático, atendimento excelente!"
    }
  ]
}

Este documento simples representa um mapa de pares de valores-chave onde:

  • As chaves são strings
  • Os valores podem ser de qualquer tipo primitivo (como booleano, número ou string) ou uma coleção (como uma lista ou mapa)

Os dados JSON válidos podem conter mapas de pares de valores-chave (usando {}) e listas (usando []). Eles podem ser combinados para criar coleções aninhadas que representam estruturas de dados complexas.

Na verdade, nosso exemplo inclui uma lista de avaliações de um determinado restaurante:

"reviews": [
  {
    "score": 4.5,
    "review": "A pizza foi incrível!"
  },
  {
    "score": 5.0,
    "review": "Pessoal muito simpático, atendimento excelente!"
  }
]

Eles são armazenados como uma lista de mapas para a chave de avaliações e cada reviews é um fragmento JSON válido por si só.

 

Codificação e decodificação JSON

Quando uma resposta JSON é enviada pela rede, toda a carga útil é codificada como uma string.

Mas dentro de nossos aplicativos Flutter, não queremos extrair os dados de uma string manualmente:

// uma carga json representada como uma string (multilinhas)
final json = '''
{
  "name": "Pizza da Mario",
  "cuisine": "Italiano",
  "reviews": [
    {
      "score": 4.5,
      "review": "A pizza foi incrível!" 
    },
    {
      "score": 5.0,
      "review": "Pessoal muito simpático, excelente serviço!"
    }
  ]
}
''';

Em vez disso, podemos decodificar o JSON primeiro para obter um mapa de pares de valores-chave que podem ser analisados mais facilmente.

Para enviar dados JSON pela rede, primeiro eles precisam ser codificados ou serializados. Codificação é o processo de transformar uma estrutura de dados em uma string. O processo oposto é chamado de decodificação ou desserialização. Ao receber uma carga JSON como uma string, você precisa decodificá-la ou desserializá-la antes de poder usá-la.

 

Decodificando JSON com dart:convert

Para simplificar, vamos considerar esta pequena carga JSON:

// isto representa alguns dados de resposta que obtemos da rede, por exemplo:
// ```
// final response = await http.get(uri);
// final jsonData = response.body
// ```
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italiano" }';

Para ler as chaves e valores dentro dele, primeiro precisamos decodificá-lo usando o pacote dart:convert:

// 1. import dart:convert
import 'dart:convert';

// isso representa alguns dados de resposta que obtemos da rede
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// 2. decodificar o json
final parsedJson = jsonDecode(jsonData);
// 3. imprima o tipo e o valor
print('${parsedJson.runtimeType} : $parsedJson');

Se executarmos este código, obteremos esta saída:

_InternalLinkedHashMap<String, dynamic> : {name: Pizza da Mario, cuisine: Italian}

Na prática, o tipo de resultado é igual a Map<String, dynamic>.

_InternalLinkedHashMap é uma implementação privada de LinkedHashMap, que por sua vez implementa Map.

Observe como os valores são do tipo dynamic. Isso faz sentido porque cada valor JSON pode ser um tipo primitivo (boolean/number/tring) ou uma coleção (list ou map).

Na verdade, jsonDecode é um método genérico que funciona em qualquer carga JSON válida, independentemente do que esteja dentro dela. Tudo o que ele faz é decodificá-lo e retornar um valor dynamic.

Mas se trabalharmos com valores dynamic no Dart, perderemos todos os benefícios de uma forte segurança de tipo. Uma abordagem muito melhor é definir algumas classes de modelo customizadas que representem nossos dados de resposta.

Como o Dart é uma linguagem de tipagem estática, é vital converter dados JSON em classes de modelo que representem objetos do mundo real (como uma receita, um funcionário, etc.) e aproveitar ao máximo o sistema de tipos.

Então vamos ver como fazer isso.

 

Analisando JSON para uma classe de modelo Dart

Dado este mapa simples que representa uma carga JSON:

{
  "name": "Pizza do Mario",
  "cuisine": "Italiano"
}

Podemos escrever uma classe Restaurant para representá-lo:

class Restaurant {
  Restaurant({required this.name, required this.cuisine});
  final String name;
  final String cuisine;
}

Podemos ler assim:

restaurant.name; // garantido como uma String imutável e não anulável
restaurant.cuisine; // garantido como uma String imutável e não anulável

Dessa forma, podemos aproveitar o sistema de tipos para obter segurança em tempo de compilação e evitar erros de digitação e outros erros.

No entanto, ainda não especificamos como converter nosso parsedJson em um objeto Restaurant!

 

JSON para Dart: Adicionando um construtor de fábrica

Vamos definir um construtor de fábrica para cuidar disso:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  // ! há um problema com este código (veja abaixo)
  final name = data['name'];
  final cuisine = data['cuisine'];
  return Restaurant(name: name, cuisine: cuisine);
}

Um construtor de fábrica é uma boa escolha para análise JSON, pois nos permite fazer algum trabalho (criar variáveis, realizar alguma validação) antes de retornar o resultado. Isto não é possível com construtores regulares (generativos).

E é assim que podemos usá-lo:

// type: String
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// type: dynamic (runtime type: _InternalLinkedHashMap<String, dynamic>)
final parsedJson = jsonDecode(jsonData);
// type: Restaurant
final restaurant = Restaurant.fromJson(parsedJson);

Muito melhor. Agora o restante do nosso código pode usar objetos Restaurant e obter todas as vantagens de uma forte segurança de tipo no Dart.

Mas há um problema. 👇

⚠️ Nota sobre conversões de tipo dinâmico

Vamos dar uma olhada mais de perto na classe que criamos:

class Restaurant {
  Restaurant({required this.name, required this.cuisine});
  final String name; // String
  final String cuisine; // String

  factory Restaurant.fromJson(Map<String, dynamic> data) {
    final name = data['name']; // dynamic
    final cuisine = data['cuisine']; // dynamic
    // conversão implícita de dinâmico para String
    return Restaurant(name: name, cuisine: cuisine);
  }
}

Como podemos ver, declaramos explicitamente as propriedades name e cuisine como strings.

Mas como os valores do nosso mapa são dynamic, o mesmo ocorre com o tipo inferido das variáveis locais name e cuisine. E o analisador estático não nos impede de passá-los como argumentos para o construtor Restaurant.

Isso é muito perigoso e levará a erros de tempo de execução difíceis de depurar se os valores JSON não forem do tipo esperado (String).

Para tornar nosso código mais seguro, podemos ativar verificações de tipo mais rigorosas personalizando nosso arquivo analyze_options.yaml:

analyzer:
  language:
    strict-raw-types: true
    strict-casts: true

Como resultado, agora obtemos um erro do compilador:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name']; // dynamic
  final cuisine = data['cuisine']; // dynamic
  // O tipo de argumento 'dinâmico' não pode ser atribuído ao tipo de parâmetro 'String'.
  return Restaurant(name: name, cuisine: cuisine);
}

Isso pode ser corrigido adicionando uma conversão explícita:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name'] as String;
  final cuisine = data['cuisine'] as String;
  return Restaurant(name: name, cuisine: cuisine);
}

Ou alternativamente, adicionando uma verificação de tipo explícita:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name'];
  final cuisine = data['cuisine'];
  if (name is String && cuisine is String) {
    return Restaurant(name: name, cuisine: cuisine);
  } else {
    throw FormatException('Invalid JSON: $data');
  }
}

Conclusão: trabalhar com variáveis dynamic pode ser perigoso. Ao habilitar strict-raw-types e strict-casts no analisador, somos forçados a tratá-los explicitamente e escrever um código mais seguro.

A seguir, vamos aprender como lidar com valores opcionais e anuláveis. 👇

 

JSON para Dart com Null Safety

Às vezes precisamos analisar algum JSON que pode ou não ter um determinado par de valores-chave.

Por exemplo, suponha que temos um campo opcional informando quando um restaurante foi aberto pela primeira vez:

{
  "name": "Ezo Sushi",
  "cuisine": "Japanese",
  "year_opened": 1990
}

Se o campo year_opened for opcional, podemos representá-lo com uma variável anulável em nossa classe de modelo.

Aqui está uma implementação atualizada para a classe Restaurant:

class Restaurant {
  Restaurant({required this.name, required this.cuisine, this.yearOpened});
  final String name; // non-nullable
  final String cuisine; // non-nullable
  final int? yearOpened; // nullable

  factory Restaurant.fromJson(Map<String, dynamic> data) {
    final name = data['name'] as String; // cast as non-nullable String
    final cuisine = data['cuisine'] as String; // cast as non-nullable String
    final yearOpened = data['year_opened'] as int?; // cast as nullable int
    return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
  }
}

Como regra geral, devemos mapear valores JSON opcionais para propriedades anuláveis do Dart. Alternativamente, podemos usar propriedades Dart não anuláveis e fornecer um valor padrão, como neste exemplo:

// nota: todas as propriedades anteriores foram omitidas por questão de simplicidade
class Restaurant {
  Restaurant({
    // 1. required
    required this.hasIndoorSeating,
  });
  // 2. *non-nullable*
  final bool hasIndoorSeating;

  factory Restaurant.fromJson(Map<String, dynamic> data) {
    // 3. lançado como *nullable* bool
    final hasIndoorSeating = data['has_indoor_seating'] as bool?;
    return Restaurant(
      // 4. usar ?? operador para fornecer um valor padrão
      hasIndoorSeating: hasIndoorSeating ?? true,
    );
  }
}

Conclusão: ao analisar valores JSON opcionais, você tem duas opções:

  • atribua-os a propriedades anuláveis que serão null quando não houver par chave-valor
  • atribua-os a propriedades não anuláveis e forneça um valor substituto quando não houver par chave-valor, usando o operador if-null (??)

 

Validação de dados JSON

Um benefício de usar construtores de fábrica é que podemos fazer alguma validação adicional, se necessário.

Por exemplo, poderíamos escrever algum código defensivo que lançasse uma FormatException se um valor obrigatório estivesse faltando ou não fosse do tipo correto:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name'];
  if (name is! String) {
    // lançará se o nome estiver faltando ou não houver uma String
    throw FormatException(
        'Invalid JSON: required "name" field of type String in $data');
  }
  final cuisine = data['cuisine'];
  if (cuisine is! String) {
    // lançará se faltar cozinha ou não houver uma String
    throw FormatException(
        'Invalid JSON: required "cuisine" field of type String in $data');
  }
  // será lançado se o valor não for nulo ou int
  final yearOpened = data['year_opened'] as int?;
  // nome e culinária são garantidos como não nulos se chegarmos a esta linha
  return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}

Ao validarmos os dados JSON, devemos trabalhar para cada campo:

  • Seu tipo (String, int, etc.)
  • Se for opcional ou não (anulável vs não anulável)
  • Quais valores são permitidos (por exemplo, a idade não pode ser inferior a zero)

Isso tornará nosso código de análise JSON mais robusto. E não teremos que lidar com dados inválidos em nossas classes de widget porque toda a validação é feita antecipadamente.

No entanto, escrever toda a lógica de validação condicional para cada campo é bastante tedioso.

E com a introdução da correspondência de padrões no Dart 3, podemos explorar uma alternativa possível. 👇

 

Validação de dados com Pattern Matching no Dart 3

Como alternativa à sintaxe acima, poderíamos escrever isto:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  if (data
      case {
        'name': String name,
        'cuisine': String cuisine,
        'year_opened': int? yearOpened, // ⚠️ aviso - veja abaixo
      }) {
    return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
  } else {
    throw FormatException('Invalid JSON: $data');
  }
}

Ao usar uma instrução if-case (nova no Dart 3), retornamos apenas um objeto Restaurant se o padrão case corresponder aos dados de entrada. Este código valida o seguinte:

  • data é um tipo de Map
  • data contém uma chave name do tipo String
  • data contém uma chave de cuisine do tipo String
  • data contém uma chave year_opened do tipo int? (⚠️ aviso, veja abaixo)

Se o valor não corresponder, o código segue para a ramificação else e lança uma exceção. Caso contrário, ele desestrutura os valores de name, cuisine e yearOpened e os utiliza para retornar um objeto Restaurant.

 

⚠️ Aviso sobre campos ausentes

No entanto, considere o que acontece se executarmos o código acima com este exemplo JSON:

void main() {
  final json = {
    'name': 'Ezo Sushi',
    'cuisine': 'Japanese',
  }
  final restaurant = Restaurant.fromJson(json); // lança FormatException
}

Nesse caso, obteremos uma FormatException porque o caso if espera encontrar um campo year_opened do tipo int?, mas esse campo está totalmente ausente. 🧐

Por outro lado, uma correspondência será encontrada se o campo year_opened existir e for null ou contiver um valor int:

void main() {
  final json = {
    'name': 'Ezo Sushi',
    'cuisine': 'Japanese',
    'year_opened': null,
  }
  final restaurant = Restaurant.fromJson(json); // ok
}

Conclusão: se usarmos a sintaxe if-case, devemos corresponder apenas aos campos que estão sempre incluídos na resposta JSON.

Em outras palavras:

  • Se um campo JSON for obrigatório (seja anulável ou não), podemos incluí-lo na sintaxe if-case e convertê-lo no tipo desejado.
  • Se um campo JSON for opcional, devemos analisá-lo separadamente.
factory Restaurant.fromJson(Map<String, dynamic> data) {
  if (data
      case {
        'name': String name,
        'cuisine': String cuisine,
      }) {
    // analise o campo year_opened aqui, pois ele pode estar faltando completamente
    final yearOpened = data['year_opened'] as int?;
    return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
  } else {
    throw FormatException('Invalid JSON: $data');
  }
}

Se você vem do mundo JavaScript, o código acima pode parecer excessivamente complicado. Isso ocorre porque, em JavaScript, a palavra-chave undefined é usada para representar valores ausentes, enquanto null é usado para valores que existem, mas estão explicitamente definidos como null. Mas no Dart não há palavra-chave undefined e temos que contornar isso por outros meios.

 

Como depurar nosso código quando a análise falha

Se você não tiver cuidado ao validar os dados JSON, seu código de análise poderá falhar se um campo esperado estiver faltando ou não for do tipo correto.

E se você estiver lidando com JSON complexo, pode ser frustrante identificar exatamente quais campos estão causando problemas.

Por exemplo, considere esta versão (atualizada) da classe Restaurant que usa correspondência de padrões:

class Restaurant {
  Restaurant({required this.name, required this.cuisine, this.yearOpened});
  final String name;
  final String cuisine;
  final int? yearOpened;

  factory Restaurant.fromJson(Map<String, dynamic> data) {
    if (data
        case {
          'name': String name,
          'cuisine': String cuisine,
        }) {
      final yearOpened = data['year_opened'] as int?;
      return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
    } else {
      throw FormatException('Invalid JSON: $data');
    }
  }
}

E suponha que o usemos para analisar este JSON

final json = {
  "name": "Ezo Sushi",
  "reviews": [
    {"score": 4.5, "review": "The pizza was amazing!"},
    {"score": 5.0, "review": "Very friendly staff, excellent service!"}
  ]
};
final restaurant = Restaurant.fromJson(json);
print(restaurant);

Se executarmos o código acima, teremos uma FormatException:

Unhandled exception:
FormatException: Invalid JSON: {name: Ezo Sushi, reviews: [{score: 4.5, review: The pizza was amazing!}, {score: 5.0, review: Very friendly staff, excellent service!}]}
#0      new Restaurant.fromJson (package:json_parsing/main.dart:17:7)

O log nos informa o arquivo e o número da linha onde o erro ocorre (main.dart:17:7), e podemos localizar facilmente a FormatException em nosso código:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  if (data
      case {
        'name': String name,
        'cuisine': String cuisine,
      }) {
    final yearOpened = data['year_opened'] as int?;
    return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
  } else {
    throw FormatException('Invalid JSON: $data'); // <-- erro acontece aqui
  }
}

Mas o log não nos informa qual campo é inválido! 😞

Nesse caso, o problema é que, como falta o campo cuisine, a afirmação if-case é refutada e terminamos no ramo else.

Para facilitar a vida, podemos tornar nosso código de tratamento de erros mais explícito no ramo else:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  if (data
      case {
        'name': String name,
        'cuisine': String cuisine,
      }) {
    // caminho feliz
    final yearOpened = data['year_opened'] as int?;
    return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
  } else {
    // caminho infeliz - lidar com erros
    if (data['name'] is! String) {
      throw FormatException('Invalid JSON: required "name" field of type String in $data');
    }
    if (data['cuisine'] is! String) {
      throw FormatException('Invalid JSON: required "cuisine" field of type String in $data');
    }
    throw FormatException('Invalid JSON: $data');
  }
}

Dessa forma, podemos identificar melhor o problema no log de erros:

FormatException: Invalid JSON: required "cuisine" field of type String in {name: Ezo Sushi, reviews: [{score: 4.5, review: The pizza was amazing!}, {score: 5.0, review: Very friendly staff, excellent service!}]}

Mas adivinhe? Podemos nos livrar da sofisticada instrução if-case e voltar à nossa implementação inicial “chata”, que analisa cada campo individualmente:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name'];
  if (name is! String) {
    // lançará se o nome estiver faltando ou não houver uma String
    throw FormatException(
        'Invalid JSON: required "name" field of type String in $data');
  }
  final cuisine = data['cuisine'];
  if (cuisine is! String) {
    // lançará se faltar cozinha ou não houver uma String
    throw FormatException(
        'Invalid JSON: required "cuisine" field of type String in $data');
  }
  // * será lançado se o valor não for nulo ou int
  final yearOpened = data['year_opened'] as int?;
  // graças às declarações if acima, name e cuisine são garantidos como não nulos aqui
  return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}

Como resultado, quando a análise falha, o rastreamento de pilha nos apontará diretamente para o próprio código de análise:

final cuisine = data['cuisine'];
if (cuisine is! String) {
  throw FormatException( // <-- stack trace will point to this line
      'Invalid JSON: required "cuisine" field of type String in $data');
}

Conclusão: a correspondência de padrões parece uma solução promissora para análise JSON, mas pode dificultar nossa vida quando a análise falha. Em vez disso, código enfadonho com verificações de tipo pode ser mais eficaz e facilitar a depuração. 👍

E se você deseja que seu código de análise seja robusto (e pronto para produção), recomendo escrever testes de unidade para garantir que todos os casos extremos possíveis sejam cobertos. ✅

 

Serialização JSON com toJson()

Analisar JSON é útil, mas às vezes também queremos converter um objeto de modelo de volta para JSON e enviá-lo pela rede.

Para fazer isso, podemos definir um método toJson para nossa classe Restaurant:

Map<String, dynamic> toJson() {
  // retornar um mapa literal com todos os pares de valores-chave não nulos
  return {
    'name': name,
    'cuisine': cuisine,
    // aqui usamos coleção-if para contabilizar valores nulos
    if (yearOpened != null) 'year_opened': yearOpened,
  };
}

E podemos usar isso assim:

// dado um objeto Restaurante
final restaurant = Restaurant(name: "Patatas Bravas", cuisine: "Spanish");
// convertê-lo em mapa
final jsonMap = restaurant.toJson();
// codifique-o em uma string JSON
final encodedJson = jsonEncode(jsonMap);
// em seguida, envie-o como um corpo de solicitação com qualquer pacote de rede

A serialização JSON é uma tarefa simples, pois nossos modelos já foram pré-validados e tudo o que precisamos fazer é convertê-los novamente em um mapa de pares chave-valor.

 

Casos de uso avançados

Até este ponto, cobrimos os fundamentos da análise JSON. Mas no mundo real, é provável que você encontre casos de uso mais complexos:

  • Analisando JSON aninhado
  • Serializando modelos aninhados
  • Como analisar JSON condicionalmente dependendo do valor de um campo
  • Lidando com valores que podem ser de vários tipos
  • Escolhendo valores profundos

Então, vamos abordá-los um por um. 👇

 

Analisando JSON aninhado: lista de mapas

JSON pode ser usado para representar matrizes (listas) e objetos (mapas), e estes podem ser aninhados para criar estruturas hierárquicas complexas.

Para descobrir como analisar JSON complexo, podemos voltar ao nosso exemplo inicial:

{
  "name": "Pizza da Mario",
  "cuisine": "Italian",
  "reviews": [
    {
      "score": 4.5,
      "review": "The pizza was amazing!"
    },
    {
      "score": 5.0,
      "review": "Very friendly staff, excellent service!"
    }
  ]
}

Como já vimos, podemos definir uma classe Restaurant para representar name e cuisine.

E como queremos usar classes de modelo e segurança de tipo o tempo todo, vamos definir uma classe Review também:

class Review {
  Review({required this.score, this.review});
  // não anulável - assumindo que o campo de pontuação esteja sempre presente
  final double score;
  // anulável - assumindo que o campo de revisão é opcional
  final String? review;

  factory Review.fromJson(Map<String, dynamic> data) {
    final score = data['score'];
    if (score is! double) {
      throw FormatException(
          'Invalid JSON: required "score" field of type double in $data');
    }
    final review = data['review'] as String?;
    return Review(score: score, review: review);
  }

  Map<String, dynamic> toJson() {
    return {
      'score': score,
      // aqui usamos coleção-if para contabilizar valores nulos
      if (review != null) 'review': review,
    };
  }
}

Então podemos atualizar a classe Restaurant para incluir uma lista de reviews:

class Restaurant {
  Restaurant({
    required this.name,
    required this.cuisine,
    this.yearOpened,
    required this.reviews,
  });
  final String name;
  final String cuisine;
  final int? yearOpened;
  final List<Review> reviews;
}

E também podemos atualizar o construtor de fábrica:

factory Restaurant.fromJson(Map<String, dynamic> data) {
  final name = data['name'];
  if (name is! String) {
    throw FormatException(
        'Invalid JSON: required "name" field of type String in $data');
  }
  final cuisine = data['cuisine'];
  if (cuisine is! String) {
    throw FormatException(
        'Invalid JSON: required "cuisine" field of type String in $data');
  }
  final yearOpened = data['year_opened'] as int?;
  final reviewsData = data['reviews'] as List<dynamic>?;
  return Restaurant(
    name: name,
    cuisine: cuisine,
    yearOpened: yearOpened,
      reviews: reviewsData != null
          ? reviewsData
              // mapear cada revisão para um objeto Review
              .map((reviewData) =>
                  Review.fromJson(reviewData as Map<String, dynamic>))
              .toList() // map() retorna um Iterable então o convertemos em uma Lista
          : <Review>[], // use uma lista vazia como valor substituto
    );
}

Algumas notas:

  • A chave reviews pode estar faltando, então a analisamos separadamente fora da instrução if-case.
  • O valor reviews é uma lista, então o convertemos como List<dynamic>?.
  • Usamos o operador .map() para converter cada valor em um objeto Review usando Review.fromJson() (uma conversão para Map<String, dynamic> também é necessária).
  • Se os reviews estiverem faltando, usamos uma lista vazia (<Review>[]) como substituto.

Observe que cada classe é responsável por analisar seus próprios campos:

  • Restaurant.fromJson analisa os campos do objeto JSON de nível superior
  • Review.fromJson analisa os comentários dentro da lista

Além disso, cada classe lançará uma FormatException se a validação falhar. Como resultado, fica muito mais fácil identificar quais campos são inválidos, especialmente quando você lida com objetos JSON complexos ou profundamente aninhados.

No exemplo acima, fizemos algumas suposições sobre o que pode ou não ser null, quais valores de fallback usar, etc. Você precisa escrever o código de análise mais apropriado para o seu caso de uso com base na especificação das APIs que você usa. você está consumindo.

 

Serializando modelos aninhados

Como último passo, aqui está o método toJson para converter um Restaurant (e todos os seus reviews) de volta em um Map:

Map<String, dynamic> toJson() {
  return {
    'name': name,
    'cuisine': cuisine,
    if (yearOpened != null) 'year_opened': yearOpened,
    'reviews': reviews.map((review) => review.toJson()).toList(),
  };
}

Observe como convertemos List<Review> de volta em List<Map<String, dynamic>>, pois precisamos serializar todos os valores aninhados também (e não apenas a própria classe Restaurant). Se você tiver vários níveis de aninhamento, deverá mapear e serializar coleções em cada nível.

Para testar o código acima, podemos criar um objeto Restaurant e convertê-lo novamente em um mapa que pode ser codificado e impresso ou enviado pela rede:

final restaurant = Restaurant(
  name: 'Pizza da Mario',
  cuisine: 'Italian',
  reviews: [
    Review(score: 4.5, review: 'The pizza was amazing!'),
    Review(score: 5.0, review: 'Very friendly staff, excellent service!'),
  ],
);
final encoded = jsonEncode(restaurant.toJson());
print(encoded);
// output: {"name":"Pizza da Mario","cuisine":"Italian","reviews":[{"score":4.5,"review":"The pizza was amazing!"},{"score":5.0,"review":"Very friendly staff, excellent service!"}]}

 

Como analisar JSON condicionalmente dependendo do valor de um campo

Suponha que você tenha uma hierarquia de classes usada para definir diferentes tipos de formas:

sealed class Shape {
  const Shape();
  double get area;
}

class Square extends Shape {
  const Square(this.side);
  final double side;

  @override
  double get area => side * side;
}

class Circle extends Shape {
  const Circle(this.radius);
  final double radius;

  @override
  double get area => pi * radius * radius;
}

E suponha que o JSON usado para representar essas formas seja assim:

const shapesJson = [
  {
    'type': 'square',
    'side': 10.0,
  },
  {
    'type': 'circle',
    'radius': 5.0,
  },
];

Aqui está o que queremos fazer neste cenário:

  • se o type for square → analise o campo side e retorne um objeto Square
  • se o type for circle → analisa o campo radius e retorna um objeto Circle
  • se os valores necessários estiverem faltando ou o tipo não for reconhecido → lançar uma exceção

A maneira mais fácil de lidar com isso é usar uma expressão switch (nova no Dart 3):

factory Shape.fromJson(Map<String, dynamic> json) {
  return switch (json) {
    {'type': 'square', 'side': double side} => Square(side),
    {'type': 'circle', 'radius': double radius} => Circle(radius),
    _ => throw FormatException('Invalid JSON: $json'),
  };
}

O código acima é muito conciso e podemos usá-lo assim:

const shapesJson = [
  {
    'type': 'square',
    'side': 10.0,
  },
  {
    'type': 'circle',
    'radius': 5.0,
  },
];
for (final json in shapesJson) {
  final shape = Shape.fromJson(json);
  print('${shape.runtimeType} area: ${shape.area}');
}

Aqui está o resultado:

Square area: 100.0
Circle area: 78.53981633974483

Mas o que acontece se omitirmos um dos campos obrigatórios?

const shapesJson = [
  {
    'type': 'square',
    //'side': 10.0, // omitted intentionally
  },
  {
    'type': 'circle',
    'radius': 5.0,
  },
];
for (final json in shapesJson) {
  final shape = Shape.fromJson(json);
  print('${shape.runtimeType} area: ${shape.area}');
}

Aqui está a saída neste caso:

Unhandled exception:
FormatException: Invalid JSON: {type: square}
#0      new Shape.fromJson (package:json_parsing/shape.dart:11:12)

O erro é bastante vago e não nos ajuda a identificar o problema.

Mas podemos consertar isso adicionando mais alguns casos ao nosso switch:

factory Shape.fromJson(Map<String, dynamic> json) {
  return switch (json) {
    // valid square
    {'type': 'square', 'side': double side} => Square(side),
    // invalid square
    {'type': 'square'} => throw FormatException(
        'Invalid JSON: required "side" field of type double in $json'),
    // valid circle
    {'type': 'circle', 'radius': double radius} => Circle(radius),
    // invalid circle
    {'type': 'circle'} => throw FormatException(
        'Invalid JSON: required "radius" field of type double in $json'),
    // invalid type
    {'type': String type} => throw FormatException(
        'Invalid JSON: shape $type is not recognized in $json'),
    // invalid JSON
    _ => throw FormatException('Invalid JSON: $json'),
  };
}

Como resultado, o log de saída é muito mais útil:

Unhandled exception:
FormatException: Invalid JSON: required "side" field of type double in {type: square}
#0      new Shape.fromJson (package:json_parsing/shape.dart:12:29)

Conclusão: podemos usar expressões switch para retornar objetos diferentes dependendo dos dados de entrada. E também podemos lidar com erros de análise, facilitando a depuração do nosso código quando o JSON é inválido.

Nota: A lógica de análise JSON condicional pode ser escrita com instruções if/else ou com a sintaxe switch conforme mostrado acima. Outra alternativa é usar o pacote Freezed, que pode ser configurado para gerar o código de análise JSON usando json_serializable. Para mais informações, leia: FromJson/ToJson.

 

Lidando com valores que podem ser de vários tipos

Às vezes, temos que lidar com APIs que não seguem um esquema JSON específico e retornam resultados inconsistentes.

Este é um problema que deveria ser corrigido no backend, mas nem sempre temos controle sobre isso.

Para contornar isso, podemos criar uma função auxiliar que verifica o tipo do valor e o converte para o tipo que desejamos. Exemplo:

String _parseValue(Map<String, dynamic> json) {
  final value = json['someValue'];
  if (value is int) {
    return value.toString();
  } else if (value is String) {
    return value;
  } else {
    throw FormatException('Invalid JSON: "someValue" should be an int or String, but ${value.runtimeType} was found inside $json');
  }
}

 

Escolhendo valores profundos com o pacote deep_pick

Analisar um documento JSON inteiro em classes de modelo com segurança de tipo é um caso de uso muito comum.

Mas às vezes, queremos apenas ler alguns valores específicos que podem estar profundamente aninhados.

Vamos considerar nosso exemplo JSON mais uma vez:

final jsonData = '''
{
  "name": "Pizza da Mario",
  "cuisine": "Italian",
  "reviews": [
    {
      "score": 4.5,
      "review": "The pizza was amazing!" 
    },
    {
      "score": 5.0,
      "review": "Very friendly staff, excellent service!"
    }
  ]
}
''';

Se quiséssemos apenas obter a pontuação da primeira avaliação, poderíamos fazer assim:

final decodedJson = jsonDecode(jsonData); // dynamic
final score = decodedJson['reviews'][0]['score'] as double;

Se tivermos desabilitado as configurações strict-raw-types e strict-casts dentro de analyze_options.yaml (não recomendado), o Dart nos permitirá aplicar vários operadores de subscrito ([]) à variável decodedJson.

Mas o código acima não é seguro para nulos nem seguro para tipos, e temos que converter explicitamente o valor analisado para o tipo que desejamos (double).

Para melhorar isso, podemos usar o pacote deep_pick, que nos permite simplificar a análise JSON com uma API de tipo seguro.

Uma vez instalado, podemos usá-lo para obter o valor desejado sem nenhuma conversão manual:

import 'dart:convert';
import 'package:deep_pick/deep_pick.dart';

final decodedJson = jsonDecode(jsonData); // dynamic
final score = pick(
  decodedJson, // input
  'reviews', // first level (map)
  0, // second level (list)
  'score', // third level (map)
).asDoubleOrThrow();

deep_pick oferece uma variedade de APIs flexíveis que podemos usar para analisar tipos primitivos, listas, mapas, objetos DateTime e muito mais. Leia a documentação para mais informações.

 

Adicionando os métodos toString e igualdade com Equatable

Ao trabalhar com classes de modelo, devemos substituir o método toString da classe Object, para que possamos imprimir facilmente os campos dentro do nosso objeto.

E também devemos substituir o operador == e o getter hashCode para que possamos comparar objetos.

Isso pode ser tedioso e sujeito a erros. Mas graças ao pacote Equatable, não precisa ser assim.

Na verdade, este é apenas o caso de estender a classe Equatable e usar a opção Quick Fix para adicionar as substituições ausentes:

import 'package:equatable/equatable.dart';

class Restaurant extends Equatable {
  final String name;
  final String cuisine;
  final int? yearOpened;
  final List<Review> reviews;

  ...

  @override
  List<Object?> get props => [name, cuisine, yearOpened, reviews];

  @override
  bool? get stringify => true;
}

// Também podemos fazer o mesmo para a classe Review.

Como resultado, podemos imprimir nosso restaurant diretamente assim:

print(restaurant);
// output: Restaurant(Pizza da Mario, Italian, null, [Review(4.5, The pizza was amazing!), Review(5.0, Very friendly staff, excellent service!)])

Adicionar métodos de igualdade às classes do modelo facilita a gravação de testes de unidade que precisam comparar objetos. E também é um requisito ao trabalhar com pacotes de gestão estadual como Bloc e Riverpod.

 

Nota sobre desempenho

Ao analisar pequenos documentos JSON, é provável que seu aplicativo permaneça responsivo e não tenha problemas de desempenho.

Mas a análise de documentos JSON muito grandes pode resultar em cálculos caros que são melhor realizados em segundo plano, em um Dart isolado separado. Os documentos oficiais têm um bom guia sobre isso:

 

Conclusão

A serialização JSON é uma tarefa muito mundana. Mas se quisermos que nossos aplicativos sejam robustos e fáceis de depurar, devemos prestar atenção aos detalhes:

  • Crie classes de modelo com métodos fromJson e toJson para todos os objetos JSON específicos de domínio em seu aplicativo.
  • Adicione sua lógica de validação dentro de fromJson para tornar o código de análise mais robusto (com uma combinação de correspondência de padrões, conversões explícitas e verificações de tipo).
  • Para dados JSON aninhados (listas de map), use o operador map e aplique os métodos fromJson e toJson conforme necessário.
  • Considere usar o pacote deep_pick para analisar JSON de maneira segura.

Se você estiver lidando com JSON complexo (muitas propriedades, vários níveis de aninhamento), também recomendo o seguinte:

  • Certifique-se de que cada classe de modelo inclua verificações de tipo para cada campo.
  • Ao lançar exceções, deixe claro o que deu errado e por quê.
  • Instale uma solução de relatório de falhas como Crashlytics ou Sentry para que você possa detectar erros na produção.

Projeto completo no meu Github (git)