Skip to content

Supported field types

The generator reads the constructor the serializer uses and maps each parameter to a validator by its type. Annotations add modifiers, and nullability and defaults decide .required(). This page is the mapping table.

Field type Generated validator Annotations that apply
String l.string() All string annotations
DateTime, DateTime? l.string().dateTime() String annotations
Uri l.string() String annotations
int l.int() @HasMin, @HasMax with integral values
double l.double() @HasMin, @HasMax
num l.num() @HasMin, @HasMax
bool l.bool()
An enum l.oneOf([...]) of the serialized values
dynamic, Object, Object? l.any()
Null l.nullValue() none; a custom validator on it fails generation
List<T>, Set<T>, Iterable<T> l.list(T's validator)
Map<K, V> l.map(keyValidator: K's, valueValidator: V's)
File from dart:io, XFile from cross_file l.file()
Any declared type with @IsFile(), including package classes such as MultipartFile l.file(), or l.file(accept:) when the annotation has one
A @luthor model M forwardRef(() => $MSchema)
A class with fromJson or @MappableClass and named parameters forwardRef(() => _$MSchema), a private auto-generated schema
A field with a JsonConverter or @JsonKey(fromJson:) l.any() only the custom validator annotations

@WithCustomValidator and @WithSchemaCustomValidator apply to every type except Null, after the type’s own modifiers.

A double field generates l.double(), which rejects a JSON integer such as 1. That mirrors the Dart type on purpose. If your backend may send 1 for a double field, type the field as num.

An enum’s serialized values come from the serializer’s rules: the case names, or @JsonValue, @JsonEnum(fieldRename:, valueField:), @MappableEnum and @MappableValue. The same applies to enums inside lists and as map keys.

enum Role { admin, member }
@Default(Role.member) Role role, // l.oneOf(["admin", "member"])
required Role role, // l.oneOf(["admin", "member"]).required()

json_serializable maps a value it does not know to the fallback given in @JsonKey(unknownEnumValue:), or to null with JsonKey.nullForUndefinedEnumValue, instead of throwing. Such a field accepts any value of the serialized type, so it generates l.string() instead of l.oneOf(), or l.int() for an enum whose serialized values are integers. On a list, set or iterable of an enum, this applies to each element. .required() follows the usual rules.

@JsonKey(unknownEnumValue: Role.member) required Role role, // l.string().required()
@JsonKey(unknownEnumValue: Role.member) required List<Role> roles, // l.list(l.string().required()).required()

After the type and its modifiers, the generator appends .required() when all of these hold: the type is not nullable, the type is not dynamic, and the parameter has no default. A default is a constructor default value, a freezed @Default, or @JsonKey(defaultValue:). Dart’s required keyword is not consulted.

required String name, // l.string().required()
String? nickname, // l.string()
required String? note, // l.string()
@Default('en') String locale, // l.string()
String locale = 'en', // l.string()
required dynamic payload, // l.any()

Fields with @JsonKey(includeFromJson: false) are left out of the schema.

Element validators follow the same rules as top-level fields, including .required() for non-nullable element types, .dateTime() for DateTime elements, and l.oneOf() for enum elements.

required List<String> tags, // l.list(l.string().required()).required()
List<String?>? notes, // l.list(l.string())
required Set<Role> roles, // l.list(l.oneOf(["admin", "member"]).required()).required()
required List<Address> places, // l.list(forwardRef(() => $AddressSchema.required())).required()

JSON object keys are strings, so a map key type of int, double, num or BigInt generates a string validator with a custom check that the string parses, and a key that fails is an invalidKey of the map. Enum keys generate l.oneOf() of the names as strings.

required Map<String, int> ages,
// l.map(keyValidator: l.string().required(), valueValidator: l.int().required()).required()
required Map<int, String> labels,
// l.map(keyValidator: l.string().custom((key) => int.tryParse(key) != null).required(), ...)

A field typed as another @luthor model refers to that model’s schema through forwardRef(). Every nested reference is wrapped, so a model that refers to itself, directly or through a list or map, and two models that refer to each other, work without annotation. @luthorForwardRef is deprecated and has no effect; remove it.

lib/node.dart
@luthor
@freezed
abstract class Node with _$Node {
const factory Node({
required String value,
List<Node>? children,
Node? parent,
}) = _Node;
factory Node.fromJson(Map<String, dynamic> json) => _$NodeFromJson(json);
}
lib/node.g.dart
final SchemaValidator $NodeSchema = l
.schema({
NodeSchemaKeys.value: l.string().required(),
NodeSchemaKeys.children: l.list(forwardRef(() => $NodeSchema.required())),
NodeSchemaKeys.parent: forwardRef(() => $NodeSchema),
})
.withName("Node");
$NodeValidate(input)+1-2
  1. valid: {'value': 'root', 'children': [{'value': 'leaf'}]}
  2. invalid: {'value': 'root', 'children': [{'value': 'a', 'children': [{'value': 1}]}]}children.0.children.0.value: value must be a string
  3. invalid: {'value': 'root', 'parent': {'value': 2}}parent.value: value must be a string

Every level is validated, including parent.

Generation fails with an InvalidGenerationSourceError that names the class, the field and the type, for:

  • freezed unions, classes with more than one redirecting factory.
  • Generic models, and fields whose type is a generic class or a type parameter.
  • Records and function types.
  • Types without a Luthor validator, such as Duration or a BigInt value. Give these a JsonConverter, which turns the field into l.any().
  • An annotation on a field type it does not apply to, such as @IsEmail on an int or @HasLength on a List.

Validators for list elements cannot be set through annotations yet.