Initial version

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-05-13 12:10:40 +02:00
parent 894f670cfd
commit eaeabb8c40
7 changed files with 446 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
## 1.0.0
- Initial version.
+89
View File
@@ -0,0 +1,89 @@
# list_or
Ever worked with an API that usually returns a list of items, but sometimes decides to return a single item just to keep you on your toes?
`list_or` is a lightweight Dart utility that fixes this exact headache. It takes an arbitrary dynamic value, whether it's a single object, an array, a `Set`, or even `null`, and normalizes it into a standard, predictable `List<T>`.
No more writing boilerplate type-checks for every flexible JSON payload.
## Features
* **Normalizes inputs:** Wraps single values into a list, or passes through existing iterables.
* **Fully mutable:** Implements `ListMixin`, meaning you can `.add()`, `.map()`, `.remove()`, and iterate over it just like any other Dart list.
* **Type-safe:** Strictly enforces your generic type `T`. If you expect a `String` and get an `int`, it throws immediately.
* **Smart null handling:** Explicitly supports `null` values only if you define the type as nullable (e.g., `ListOr<String?>`).
* **Clean stringification:** If the list only has one item, `toString()` prints just that item instead of adding `[ ]` brackets.
## Getting Started
Add the package to your `pubspec.yaml`:
```bash
dart pub add list_or
```
Or, if you're using Flutter:
```bash
flutter pub add list_or
```
Import it in your file:
```dart
import 'package:list_or/list_or.dart';
```
## Usage
The most common use case is parsing unpredictable JSON.
Imagine an API endpoint where the `tags` field might be a single string or an array of strings depending on how many tags the user added.
```dart
import 'package:list_or/list_or.dart';
void main() {
// Scenario A: The API returns a single string
final Map<String, dynamic> json1 = {'id': 1, 'tags': 'flutter'};
// Scenario B: The API returns an array of strings
final Map<String, dynamic> json2 = {'id': 2, 'tags': ['dart', 'backend']};
// ListOr normalizes both seamlessly.
final tags1 = ListOr<String>(json1['tags']);
final tags2 = ListOr<String>(json2['tags']);
print(tags1); // Output: flutter
print(tags2); // Output: [dart, backend]
// You can interact with it exactly like a normal list
tags1.add('mobile');
print(tags1.length); // Output: 2
print(tags1.contains('mobile')); // Output: true
}
```
### Handling Nulls
If your payload might intentionally contain nulls, just make your generic type
nullable:
```dart
// This works perfectly
final nullableList = ListOr<int?>(null);
print(nullableList.length); // Output: 1
// This will throw an ArgumentError to keep your types safe
final strictList = ListOr<int>(null);
```
## Additional Information
* **Found a bug?** Open an issue on the issue tracker.
* **Want to contribute?** Pull requests are welcome. Please ensure that all existing tests pass and add new tests for any new functionality.
+30
View File
@@ -0,0 +1,30 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
+56
View File
@@ -0,0 +1,56 @@
import 'package:list_or/list_or.dart';
void main() {
print('--- Example 1: Wrapping a Single Value ---');
// A single value is converted into a list containing just that value.
final singleItem = ListOr<String>('Hello World');
print('Length: ${singleItem.length}'); // Output: 1
print('First item: ${singleItem[0]}'); // Output: Hello World
// Notice the custom toString():it prints just the string, not ['Hello World']
print('String representation: $singleItem'); // Output: Hello World
print('\n--- Example 2: Wrapping an Iterable ---');
// A standard List or Set is normalized into a modifiable ListOr.
final multipleItems = ListOr<String>(['Apple', 'Banana']);
// Because it mixes in ListMixin, you can use all standard list methods.
multipleItems.add('Cherry');
print('Length: ${multipleItems.length}'); // Output: 3
// When the length is not exactly 1, toString() acts like a normal List.
print(
'String representation: $multipleItems',
); // Output: [Apple, Banana, Cherry]
print('\n--- Example 3: Parsing Flexible JSON Data ---');
// Imagine an API that irregularly returns 'tags' as either a String or a List.
final Map<String, dynamic> jsonResponse1 = {'id': 1, 'tags': 'flutter'};
final Map<String, dynamic> jsonResponse2 = {
'id': 2,
'tags': ['dart', 'backend'],
};
// ListOr cleanly normalizes both scenarios without complex type checking.
final tags1 = ListOr<String>(jsonResponse1['tags']);
final tags2 = ListOr<String>(jsonResponse2['tags']);
print(
'Tags 1 contains "flutter": ${tags1.contains('flutter')}',
); // Output: true
print('Tags 2 length: ${tags2.length}'); // Output: 2
print('\n--- Example 4: Handling Nulls with Nullable Types ---');
// If your data might explicitly be null and you want to keep that null
// as a valid single element, you must use a nullable generic type.
final nullableList = ListOr<int?>(null);
print('Nullable list length: ${nullableList.length}'); // Output: 1
print('First item: ${nullableList.first}'); // Output: null
// Note: Trying to do the following would throw an ArgumentError,
// keeping your types safe:
// final strictList = ListOr<int>(null); // THROWS!
}
+118
View File
@@ -0,0 +1,118 @@
/// A utility library for handling flexible data structures.
///
/// This library provides the `ListOr` class, which normalizes single values or
/// iterables into a unified `List` interface. It is particularly useful for
/// parsing JSON or dynamic data where a field might interchangeably be provided
/// as a single element or a collection of elements.
library;
import 'dart:collection';
/// A list implementation that normalizes a single value or an iterable into a
/// [List<T>].
///
/// This class mixes in [ListMixin] to provide a complete list API backed by an
/// internal list. It is particularly useful when parsing flexible data
/// structures (such as JSON) where a field might be provided as either a single
/// element or a collection of elements.
class ListOr<T> extends Object with ListMixin<T> {
/// The underlying list storing the elements.
final List<T> _items;
/// Creates a [ListOr] instance from an arbitrary [value].
///
/// The factory normalizes the [value] based on its type:
/// * If [value] is an [Iterable] (e.g., `List` or `Set`), it creates a new
/// modifiable list and casts the elements to [T].
/// * If [value] is exactly of type [T] (including `null` if [T] is nullable),
/// it wraps the value in a single-element list.
///
/// Throws an [ArgumentError] if [value] is `null` but the generic type [T] is
/// non-nullable.
/// Throws an [ArgumentError] if the [value] is neither of type [T] nor an
/// [Iterable] compatible with [T].
factory ListOr(Object? value) {
if (value is Iterable) {
return ListOr._(List<T>.from(value));
}
if (value is T) {
return ListOr._(<T>[value]);
}
if (value == null) {
throw ArgumentError(
'The type $T does not accept null values. '
'Pass a value of type $T or use ListOr<$T?> instead.',
);
}
throw ArgumentError(
'Value must be of type $T or Iterable containing $T. Got ${value.runtimeType}',
);
}
/// Internal constructor to initialize the backing list.
ListOr._(this._items);
/// Returns the number of elements in the list.
@override
int get length => _items.length;
/// Updates the length of the list.
@override
set length(int newLength) {
_items.length = newLength;
}
/// Returns the element at the given [index].
@override
T operator [](int index) => _items[index];
/// Sets the element at the given [index] to [value].
@override
void operator []=(int index, T value) {
_items[index] = value;
}
/// Adds [element] to the end of this list.
@override
void add(T element) => _items.add(element);
/// Appends all objects of [iterable] to the end of this list.
@override
void addAll(Iterable<T> iterable) => _items.addAll(iterable);
/// Inserts [element] at position [index] in this list.
@override
void insert(int index, T element) => _items.insert(index, element);
/// Removes the object at position [index] from this list.
///
/// Returns the removed element.
@override
T removeAt(int index) => _items.removeAt(index);
/// Removes the first occurrence of [element] from this list.
///
/// Returns `true` if [element] was in the list, `false` otherwise.
@override
bool remove(Object? element) => _items.remove(element);
/// Removes all objects from this list; the length of the list becomes zero.
@override
void clear() => _items.clear();
/// Returns a string representation of this object.
///
/// If the list contains exactly one element, this returns the string
/// representation of that single element. Otherwise, it returns the standard
/// bracketed list representation (e.g., `[a, b]` or `[]`).
@override
String toString() {
if (_items.length == 1) {
return _items.firstOrNull.toString();
}
return _items.toString();
}
}
+12
View File
@@ -0,0 +1,12 @@
name: list_or
description: A Dart utility that seamlessly normalizes single values and iterables into a unified, type-safe List.
version: 1.0.0
repository: https://git.hadak.org/dart/list_or
issue_tracker: https://git.hadak.org/dart/list_or/issues
environment:
sdk: ^3.0.0
dev_dependencies:
lints: ^6.0.0
test: ^1.25.6
+138
View File
@@ -0,0 +1,138 @@
import 'package:list_or/list_or.dart';
import 'package:test/test.dart';
void main() {
group('ListOr Initialization', () {
test('creates from a single value', () {
final list = ListOr<String>('hello');
expect(list.length, equals(1));
expect(list.first, equals('hello'));
expect(list, equals(['hello']));
});
test('creates from a List', () {
final list = ListOr<int>([1, 2, 3]);
expect(list.length, equals(3));
expect(list, equals([1, 2, 3]));
});
test('creates from a Set (Iterable)', () {
final list = ListOr<int>({4, 5, 6});
expect(list.length, equals(3));
expect(list, equals([4, 5, 6]));
});
test('handles null correctly when T is nullable', () {
final list = ListOr<int?>(null);
expect(list.length, equals(1));
expect(list.first, isNull);
expect(list, equals([null]));
});
test('throws ArgumentError when null is passed but T is non-nullable', () {
expect(
() => ListOr<int>(null),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('does not accept null values'),
),
),
);
});
test('throws ArgumentError on incompatible types', () {
expect(
() => ListOr<String>(123),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('Value must be of type'),
),
),
);
});
});
group('ListOr Mutations (Bug Fix Verification)', () {
test('can add to a single-initialized list without throwing', () {
final list = ListOr<String>('Apple');
list.add('Banana'); // This would throw prior to the explicit override fix
expect(list, equals(['Apple', 'Banana']));
});
test('can addAll from another iterable', () {
final list = ListOr<int>(1);
list.addAll([2, 3, 4]);
expect(list, equals([1, 2, 3, 4]));
});
test('can insert at a specific index', () {
final list = ListOr<String>(['A', 'C']);
list.insert(1, 'B');
expect(list, equals(['A', 'B', 'C']));
});
test('can update elements using index operator', () {
final list = ListOr<int>([10, 20, 30]);
list[1] = 99;
expect(list, equals([10, 99, 30]));
});
test('can remove a specific element', () {
final list = ListOr<String>(['A', 'B', 'C']);
final result = list.remove('B');
expect(result, isTrue);
expect(list, equals(['A', 'C']));
});
test('can remove at a specific index', () {
final list = ListOr<int>([1, 2, 3]);
final removed = list.removeAt(0);
expect(removed, equals(1));
expect(list, equals([2, 3]));
});
test('can clear the list entirely', () {
final list = ListOr<String>(['A', 'B']);
list.clear();
expect(list, isEmpty);
expect(list.length, equals(0));
});
});
group('ListOr toString formatting', () {
test('formats a single element as just the element string', () {
final list = ListOr<String>('hello');
expect(list.toString(), equals('hello'));
});
test('formats multiple elements with standard list brackets', () {
final list = ListOr<int>([1, 2, 3]);
expect(list.toString(), equals('[1, 2, 3]'));
});
test('formats empty list with standard empty brackets', () {
final list = ListOr<String>(<String>[]);
expect(list.toString(), equals('[]'));
});
test('formats single null element correctly', () {
final list = ListOr<String?>(null);
expect(list.toString(), equals('null'));
});
});
}