json_sentinel

CI pub.dev codecov API docs

Lightweight runtime JSON validation for Dart — configurable logger, redaction, and batch validation — routes failures to any error reporter. No code generation required.

Validates a Map<String, dynamic> against an expected schema before you deserialise it, catching malformed API responses early with a single, readable log entry per failure.

Features

  • Checks key existence and value types in one call
  • Nullable fields, union types ([int, double]), and optional fields all supported
  • Strict mode flags unexpected keys not declared in your schema
  • warnUnexpected — logs unknown keys as warnings without failing validation; ideal for APIs that add fields in minor versions
  • validators — per-key predicate functions for value-level checks (enums, ranges, non-empty strings) run after type validation passes
  • Single log entry per call listing every problem as a bullet — nothing hidden by an early return
  • validateList() — validates a bare List<dynamic> from jsonDecode directly; non-Map items produce descriptive failures rather than cast errors
  • validateBatch() — validates a list of payloads against a shared schema and emits one consolidated log entry for all failures, preventing duplicate error reporter events when processing list endpoints
  • json_preview attached to single-item extras; item_previews (List<String>) attached to batch extras — one JSON preview per failing item (opt out with generatePreviews: false)
  • parentContext — chain validate() calls across nested models to produce [UserPage.data[2] > MetaModel] log prefixes, pinpointing failures in paginated or deeply-nested responses without extra logging code
  • redactKeys — mask sensitive top-level field values (passwords, tokens, API keys) in json_preview and item_previews before they reach your error reporter; custom redactionPlaceholder supported
  • Configurable escalate flag per call — control whether a failure is a breadcrumb or a full capture
  • Returns JsonValidationResult with isValid + programmatic errors list; validateBatch() returns BatchValidationResult with per-item results, failureCount, and failureIndices
  • silence() for pure programmatic use with no log output
  • Optional verbose: true for debug-mode traces via dart:developer — fires on init, every passing and failing call, and json_preview serialisation fallback
  • Zero runtime dependencies — pure Dart, works in Flutter, server, and CLI

Getting started

dependencies:
  json_sentinel: ^0.4.0

Usage

1. Wire in your logger once at startup

JsonSentinel.configure(
  (message, {error, stackTrace, extras, escalate}) {
    // escalate: true → full capture; false → breadcrumb
    if (escalate == true) {
      Sentry.captureMessage(message, hint: Hint.withMap(extras ?? {}));
    } else {
      Sentry.addBreadcrumb(Breadcrumb(message: message));
    }
  },
  verbose: true, // optional: dart:developer traces in debug mode
);

If configure() has not been called, failures fall back to dart:developer (visible in DevTools Logging; suppressed in release builds). Always call configure() or silence() in production code. Use JsonSentinel.logger to check whether a logger is already registered.

No logging needed?

Use silence() when you only want programmatic access to result.errors:

JsonSentinel.silence(); // installs a no-op logger — no output of any kind

silence() and configure() are mutually exclusive — call one or the other, never both.

2. Validate

No model class required — validate any Map<String, dynamic> directly:

final result = JsonSentinel.validate(
  json: responseMap,
  expectedTypes: {'id': [int], 'name': [String], 'active': [bool]},
  context: 'UserInput',
);
if (result.isValid) {
  // types confirmed — safe to cast and use
  final id = responseMap['id'] as int;
}

Useful for scripts, middleware, configuration checks, and tests where you don't need a full model class — just a shape check before you proceed.

In a tryFromJson factory

For structured deserialisation, validate before casting:

static ProductListing? tryFromJson(Map<String, dynamic> json) {
  final result = JsonSentinel.validate(
    json: json,
    expectedTypes: {
      'productId':   [int],
      'sku':         [String],
      'price':       [int, double],   // union — API may return either
      'description': [String, null],  // nullable
    },
    optional: {'description'},        // absent is fine; type-checked if present
    context: 'ProductListing',
    escalate: true,
  );
  if (!result.isValid) return null;

  return ProductListing(
    productId:   json['productId'] as int,
    sku:         json['sku'] as String,
    price:       (json['price'] as num).toDouble(),
    description: json['description'] as String?,
  );
}

3. Batch validation

validateBatch() validates a list of payloads against a shared schema and emits exactly one consolidated log entry — no matter how many items fail. Use it instead of calling validate() in a loop when failures should produce a single error reporter event.

final batch = JsonSentinel.validateBatch(
  jsons: payloads,              // List<Map<String, dynamic>>
  expectedTypes: {
    'id':   [int],
    'name': [String],
    'role': [String],
  },
  optional: {'role'},           // same optional/strict/escalate semantics as validate()
  context: 'UserRecord',
  escalate: true,
  generatePreviews: true,       // set false to skip jsonEncode on each failing item
);

BatchValidationResult fields:

Field Type Description
isValid bool true only when every item passed
results List<JsonValidationResult> One result per input item, in order
failureCount int Number of failing items
failureIndices List<int> Zero-based indices of failing items

Converting results to models

// Pattern A — strict: abort if any item is invalid
if (!batch.isValid) return null;

// Pattern B — lenient: skip invalid items, convert passing ones
final users = [
  for (var i = 0; i < payloads.length; i++)
    if (batch.results[i].isValid) UserRecord.fromValidJson(payloads[i]),
];

// Pattern C — all failed
if (batch.failureCount == payloads.length) {
  // nothing valid to process
}

Batch extras for your error reporter

On failure the logger receives extras with 'context', 'failure_count' (int), 'total_count' (int), and 'item_previews' — a List<String> of truncated JSON snapshots for each failing item, in failureIndices order. Pass generatePreviews: false to omit item_previews and skip JSON serialisation for all failing items (useful for large high-failure batches):

Sentry.captureMessage(message, hint: Hint.withMap({
  'previews': extras?['item_previews'], // List<String>
}));

List validation (bare JSON arrays)

When an endpoint returns a bare JSON array — not a keyed envelope — use validateList() instead of manually casting and filtering before validateBatch():

final batch = JsonSentinel.validateList(
  raw: jsonDecode(response) as List<dynamic>, // directly from jsonDecode
  expectedTypes: {'id': [int], 'name': [String]},
  context: 'UserRecord',
);

Non-Map items at index N produce a failure ("Item N is not a Map<String, dynamic>.") counted in failureIndices rather than throwing a cast error. Map items are validated identically to validateBatch().

tryFromListJson pattern

// Full-or-nothing: return null if any item fails.
static List<UserRecord>? tryFromListJson(List<dynamic> raw) {
  final batch = JsonSentinel.validateList(
    raw: raw,
    expectedTypes: {'id': [int], 'name': [String]},
    context: 'UserRecord',
  );
  if (!batch.isValid) return null;
  return [
    for (final item in raw)
      if (item is Map<String, dynamic>) UserRecord.fromValidJson(item),
  ];
}

// Skip-bad-items: return only valid items using failureIndices.
static List<UserRecord> fromValidListJson(List<dynamic> raw) {
  final batch = JsonSentinel.validateList(
    raw: raw,
    expectedTypes: {'id': [int], 'name': [String]},
    context: 'UserRecord',
  );
  return [
    for (int i = 0; i < raw.length; i++)
      if (!batch.failureIndices.contains(i) && raw[i] is Map<String, dynamic>)
        UserRecord.fromValidJson(raw[i] as Map<String, dynamic>),
  ];
}

validateList() accepts the same optional parameters as validateBatch(): optional, strict, warnUnexpected, validators, escalate, and generatePreviews.

Paginated responses

When a list endpoint wraps items in an envelope object, use validate() for the outer shape and validateBatch() for the items inside. This produces at most two log entries — one if the envelope itself is malformed (worth escalating), one covering all item failures combined (a single breadcrumb, not one event per bad record).

static PaginatedUserResponse? tryFromJson(Map<String, dynamic> json) {
  // Step 1: validate the outer envelope — structural errors here are worth escalating.
  final envelope = JsonSentinel.validate(
    json: json,
    expectedTypes: {
      'current_page': [int],
      'total':        [int],
      'data':         [List],
    },
    context: 'UserPage',
    escalate: true,
  );
  if (!envelope.isValid) return null;

  // Step 2: validate every item inside data — one breadcrumb for all failures combined.
  // Filter non-Map elements rather than using a lazy cast that throws at runtime.
  final items = [
    for (final item in json['data'] as List)
      if (item is Map<String, dynamic>) item,
  ];
  final batch = JsonSentinel.validateBatch(
    jsons: items,
    expectedTypes: {
      'id':   [int],
      'name': [String],
      'role': [String],
    },
    context: 'UserRecord',
    escalate: false,
  );

  // Step 3: return the page with valid items (lenient: skip bad records, keep good ones).
  return PaginatedUserResponse(
    currentPage: json['current_page'] as int,
    total:       json['total'] as int,
    users: [
      for (var i = 0; i < items.length; i++)
        if (batch.results[i].isValid) UserRecord.fromValidJson(items[i]),
    ],
  );
}

Nested JSON

Validate the outer shape at each level; let child models validate their own fields. Failures carry the child's own context string, so log entries are always pinpointed.

// Parent: validates top-level keys as List/Map only.
static PaginatedResponse? tryFromJson(Map<String, dynamic> json) {
  final result = JsonSentinel.validate(
    json: json,
    expectedTypes: {
      'data':  [List],
      'links': [Map],
      'meta':  [Map],
    },
    context: 'PaginatedResponse',
    escalate: true,
  );
  if (!result.isValid) return null;

  final meta = MetaModel.tryFromJson(
    Map<String, dynamic>.from(json['meta'] as Map),
  );
  if (meta == null) return null;
  // ...
}

// Child: validates its own schema, including nullable fields.
static MetaModel? tryFromJson(Map<String, dynamic> json) {
  final result = JsonSentinel.validate(
    json: json,
    expectedTypes: {
      'current_page': [int],
      'from':         [null, int],  // null when page is empty
      'last_page':    [int],
      'links':        [List],
      'path':         [String],
      'per_page':     [int],
      'to':           [null, int],  // null when page is empty
      'total':        [int],
    },
    context: 'MetaModel',
    escalate: true,
  );
  if (!result.isValid) return null;
  // ...
}

parentContext — chained paths across nested models

When calling validate() in a loop over nested items, pass parentContext to include the parent path in every log entry. Without it, errors from index 1, 2, and 19 all read [MetaModel] — with it, the log pinpoints each one:

for (var i = 0; i < items.length; i++) {
  JsonSentinel.validate(
    json: items[i]['meta'] as Map<String, dynamic>,
    expectedTypes: {'total': [int], 'per_page': [int]},
    context: 'MetaModel',
    parentContext: 'UserPage.data[$i]',
  );
}
// [UserPage.data[1] > MetaModel] JSON validation failed (1 error):
//   • Missing required key 'total'.

The combined path is also stored in extras['context'], so error reporter breadcrumbs and fingerprints are accurate without any extra work.

validateBatch() does not accept parentContext — it builds its own indexed path (Item 1, Item 2, …) internally.

Optional fields

Keys listed in optional are skipped when absent but still type-checked when present:

JsonSentinel.validate(
  json: json,
  expectedTypes: {'id': [int], 'nickname': [String]},
  optional: {'nickname'},
);

Strict mode

Reject keys present in the JSON but not declared in your schema:

JsonSentinel.validate(
  json: json,
  expectedTypes: {'id': [int]},
  strict: true,
);

warnUnexpected — API drift without failures

strict: true fails validation on any unknown key. warnUnexpected: true logs them as warnings instead — result.isValid remains true — so your app keeps working while you are notified of API contract drift:

// API v2 added 'created_at' — your v1 schema doesn't know about it yet.
final result = JsonSentinel.validate(
  json: responseMap,
  expectedTypes: {'id': [int], 'status': [String]},
  warnUnexpected: true,
  context: 'UserRecord',
);
// [WARN] [UserRecord] JSON validation warning (1 unexpected key):
//   • Unexpected key 'created_at'.
// result.isValid → true

warnUnexpected and strict are mutually exclusive (asserted in debug mode). Warning logs always use escalate: false. Works identically in validateBatch() and validateList(), where warnings are consolidated into a single batch warning log.

Per-field validators

validators accepts a Map<String, bool Function(Object?)> of predicate functions run after type validation passes for each key. Use it for lightweight domain constraints — allowed enum values, numeric ranges, non-empty strings:

final result = JsonSentinel.validate(
  json: orderJson,
  expectedTypes: {
    'status': [String],
    'quantity': [int],
  },
  validators: {
    'status': (v) => const ['active', 'inactive', 'pending'].contains(v),
    'quantity': (v) => (v as int) > 0,
  },
  context: 'OrderRecord',
);
// [WARN] [OrderRecord] JSON validation failed (2 errors):
//   • Key 'status' failed custom validation.
//   • Key 'quantity' failed custom validation.

Predicates are skipped for absent optional keys and for keys that already failed type validation. Works identically in validateBatch() and validateList().

Redaction

Pass redactKeys to configure() to mask sensitive top-level field values in every json_preview and item_previews snapshot sent to your error reporter. Values matching a listed key are replaced with '[REDACTED]' (or your custom placeholder) before the JSON is encoded — the validated data itself is never modified.

JsonSentinel.configure(
  (message, {error, stackTrace, extras, escalate}) { ... },
  redactKeys: {'password', 'token', 'apiKey', 'secret'},
  redactionPlaceholder: '[REDACTED]', // optional, this is the default
);

Given {'userId': 42, 'password': 'hunter2', 'token': 'abc123'}, the json_preview in extras becomes:

{"userId": 42, "password": "[REDACTED]", "token": "[REDACTED]"}

Redaction applies to both json_preview (single-item) and item_previews (batch) — no separate configuration needed. Only top-level keys are redacted; nested object contents are not inspected.

silence() accepts the same redactKeys and redactionPlaceholder parameters for consistency.

Programmatic error access

final result = JsonSentinel.validate(json: json, expectedTypes: schema);
if (!result.isValid) {
  for (final error in result.errors) {
    print(error);
  }
}

Example log output

Single-item failure:

[ProductListing] JSON validation failed (2 errors):
  • Key 'productId' has invalid type. Expected: int; Actual: String.
  • Missing required key 'sku'.

Single-item failure with parentContext:

[UserPage.data[2] > MetaModel] JSON validation failed (1 error):
  • Missing required key 'total'.

Batch failure:

[UserRecord] JSON batch validation failed (2 of 4 items failed):
  Item 1 (1 error):
    • Missing required key 'role'.
  Item 2 (1 error):
    • Key 'id' has invalid type. Expected: int; Actual: String.

Testing

configure() and silence() both assert in debug mode if called twice. Call JsonSentinel.resetLoggerForTesting() in tearDown to allow re-configuration across test cases:

setUp(() {
  JsonSentinel.configure((message, {error, stackTrace, extras, escalate}) {
    logs.add(message);
  });
});
tearDown(JsonSentinel.resetLoggerForTesting);

Schema reference

Type list value Meaning
[int] Required, must be int
[String] Required, must be String
[bool] Required, must be bool
[double] Required, must be double
[num] Required, int or double — prefer [int, double] when you want to signal intent explicitly
[int, double] Required, int or double (explicit union)
[Map] Required, any Map
[List] Required, any List
[String, null] Required, String or null
[null, int] Required, null or int
null or [] Required key, type not checked

Add the key to optional to make presence itself optional.

Additional information

Libraries

json_sentinel
Lightweight runtime JSON key and type validation for Dart.