Generate Type-Safe Field Name Accessors
## Problem Statement
`json_serializable` excels at converting JSON data to type-safe Dart models, which works seamlessly with Firebase's `.withConverter()` methods for collection and document references. However, when performing partial updates (e.g., updating a single field in a Firestore document), developers must use string literals for field names, making the code prone to typos and breaking during refactors.
> This can also be applied to anything that supports partial data updates with json format, not just Firebase. But I've taken Firebase as an example as that's what I am more familiar with.
### Current Workflow
Consider this simple model:
```dart
@JsonSerializable()
class User {
final String name;
final int age;
final bool isActive;
User({required this.name, required this.age, required this.isActive});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
```
**Full model updates** are type-safe:
```dart
ref.set(user.toJson());
// or with converter
ref.set(user);
```
**Partial updates** require string literals:
```dart
ref.update({'isActive': false}); // ❌ No type safety, prone to typos
// Even worse with field renaming:
@JsonSerializable(fieldRename: FieldRename.snake)
//
ref.update({'is_active': false}); // ❌ Must remember the rename convention
```
### Issues with Current Approach
1. **No compile-time safety**: Typos in field names aren't caught until runtime
2. **Refactoring risks**: Renaming a field in the model doesn't update string literals
3. **Field rename confusion**: Developers must remember the configured naming convention
4. **No IDE support**: No autocomplete or "find usages" for field names as strings
### Existing Workaround Limitations
The `createFieldMap: true` option generates a `Map<String, String>`, but this doesn't integrate well with most update APIs and lacks the ergonomics of direct field access.
## Proposed Solution
Generate a companion class with const field accessors that provide type-safe, refactor-friendly field name references.
### Basic Usage
```dart
ref.update({User.fields.isActive: false}); // ✅ Type-safe, autocomplete-friendly
```
### Generated Code
```dart
final class $UserFields {
const $UserFields();
final String name = 'name';
final String age = 'age';
final String isActive = 'is_active'; // Respects fieldRename configuration
}
```
### Integration with Model
```dart
@JsonSerializable(generateFieldsClass: true)
class User {
final String name;
final int age;
final bool isActive;
User({required this.name, required this.age, required this.isActive});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
static const $UserFields fields = $UserFields(); // Generated accessor
}
```
### Benefits
- ✅ **Compile-time safety**: Typos caught immediately
- ✅ **Refactor-friendly**: Renaming a field updates all references
- ✅ **IDE support**: Full autocomplete and "find usages"
- ✅ **Convention-aware**: Automatically respects `fieldRename` and other `@JsonKey` configurations
## Supporting Nested Structures
The basic approach works perfectly for flat models, but real-world applications often use nested structures. Firebase and similar databases support dot-notation paths (e.g., `address.city`) for updating nested fields.
### Desired Usage
```dart
// Update nested fields with type safety
ref.update({
User.fields.name: 'John',
User.fields.address.city: 'San Francisco',
User.fields.address.zipCode: '94102',
});
print(User.fields.address.city); // Outputs: "address.city"
```
### Example Models
```dart
@JsonSerializable(generateFieldsClass: true)
class User {
final String name;
final int age;
final bool isActive;
final Address address;
User({required this.name, required this.age, required this.isActive, required this.address});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
static const $UserFields fields = $UserFields();
}
@JsonSerializable(generateFieldsClass: true)
class Address {
final String street;
final String city;
final String zipCode;
Address({required this.street, required this.city, required this.zipCode});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
static const $AddressFields fields = $AddressFields();
}
```
### Generated Code Using Extension Types
```dart
extension type const $UserFields.prefixed(String prefix) implements String {
const $UserFields() : prefix = '';
String _join(String field) => prefix.isEmpty ? field : '$prefix.$field';
String get name => _join('name');
String get age => _join('age');
String get isActive => _join('is_active');
$AddressFields get address => $AddressFields.prefixed(_join('address'));
}
extension type const $AddressFields.prefixed(String prefix) implements String {
const $AddressFields() : prefix = '';
String _join(String field) => prefix.isEmpty ? field : '$prefix.$field';
String get street => _join('street');
String get city => _join('city');
String get zipCode => _join('zip_code');
}
```
### How It Works
1. Extension types allow chaining while building the path string
2. The `prefix` parameter accumulates the parent path
3. Each getter either returns a final path or another extension type for further nesting
4. Works at any nesting level with zero runtime overhead (const constructors)
### Configuration Options
To support different use cases, the feature could include:
```dart
@JsonSerializable(
generateFieldsClass: true,
fieldPathSeparator: '.', // Default, configurable for other databases
fieldPathPrefix: 'user', // Optional prefix for all paths
)
class User { ... }
```
---
## Why This Belongs in `json_serializable`
While this functionality could be implemented as a separate package, integrating it into `json_serializable` offers significant advantages:
1. **Unified configuration**: Automatically respects all `@JsonSerializable` options
- `fieldRename` (snake_case, kebab-case, etc.)
- `@JsonKey(name: 'custom_name')` annotations
- `includeIfNull`, `explicitToJson`, and other settings
2. **Single source of truth**: Field definitions live in one place
3. **Consistent generation**: Uses the same build pipeline and conventions
4. **No duplication**: Doesn't require re-parsing or separate configuration
5. **Better DX**: One annotation enables both serialization and field accessors
## Conclusion
This feature would significantly improve the developer experience when working with partial updates in type-safe Dart applications. It eliminates an entire class of runtime errors while maintaining the zero-cost abstraction philosophy through const values and extension types.
The nested structure support using extension types is elegant, performant, and scales to any level of nesting. While the implementation might seem complex, it's a natural extension of the existing code generation logic that `json_serializable` already performs.
I believe this would be a valuable addition that complements the existing functionality and addresses a real pain point for many developers using `json_serializable` with Firebase, Isar, and similar databases.
Thank you for considering this feature request!
4 条评论