skip to content

In Dart, how would you type and compose a validator pipeline where each validator is a `String? Function(String)`?

level: middleimportance: should knowfreq 36%

answer

  1. name the shape once
  2. null means valid
  3. factories return configured functions
  4. combinator: list in, one function out
  5. first non-null error wins

basics

~10 s

Name the shape once with typedef Validator = String? Function(String value);, write factory functions that return configured validators, and a combinator that takes a List<Validator> and returns one Validator reporting the first non-null error.

solid answer

~40 s

Start with an alias, `typedef Validator = String? Function(String value);`, where `null` means valid and a string is the error. Parameterised rules become **higher-order factories** that return closures: `Validator minLength(int n) => (v) => v.length < n ? 'Too short' : null;`, so `minLength(8)` fixes the configuration and hands back a function. Composition is one more function that takes validators and returns a validator: `Validator all(List<Validator> rules)` runs them in order and returns the first error. Because every piece is typed, the analyzer infers `v` as `String` inside each lambda and rejects a rule with the wrong shape. An extension on the function type can add fluent `and` chaining, and a generic `compose<A, B, C>` lets Dart infer its type arguments from the functions you pass.

code

dart · 17 lines
dart
typedef Validator = String? Function(String value);

String? notEmpty(String v) => v.trim().isEmpty ? 'Required' : null;

Validator minLength(int n) =>
    (v) => v.length < n ? 'At least $n characters' : null;

extension ValidatorOps on Validator {
  Validator and(Validator next) => (v) => this.call(v) ?? next(v);
}

void main() {
  final username = notEmpty.and(minLength(3));
  print(username('  ')); // Required
  print(username('ab')); // At least 3 characters
  print(username('ada')); // null
}

go deeper

for a junior

Recall that a function can return another function, and that a validator can simply be a function returning an error string or null.

for a middle

Write the alias, a factory with a function return type and a combinator whose output has the same type as its inputs, and explain how context types infer lambda parameters.

for a senior

Discuss ordering and short-circuiting, building pipelines once rather than per keystroke, and when to add extension methods or collect all errors.

for a principal

Decide how far to take function composition in a codebase: small typed combinators read well, while deep generic stacks cost debuggability and onboarding time.

## The shape: a typed alias A validator here is a function from the raw input to an optional error message. Returning `null` means *valid*; returning a `String` means *invalid, and here is why*. Naming the shape once keeps every signature below short: ```dart typedef Validator = String? Function(String value); ``` The alias is structural, so any function taking a `String` and returning `String?` already is a `Validator`; plain top-level functions and lambdas both qualify. ## Configured rules as factories Some rules need parameters: a minimum length, a pattern, a list of forbidden words. Rather than passing those at every call, write a **factory**: a function that takes the configuration and **returns a function**. ```dart String? notEmpty(String v) => v.trim().isEmpty ? 'Required' : null; Validator minLength(int n) => (v) => v.length < n ? 'At least $n characters' : null; Validator matches(RegExp pattern, String message) => (v) => pattern.hasMatch(v) ? null : message; ``` Written without the alias, `minLength` has the type `String? Function(String) Function(int)`: the outermost function's parameter list is the last one, `(int)`, and everything before `Function(int)` is its return type. This is where an alias clearly earns its keep. The returned lambda is a **closure** over `n`; its parameter `v` needs no annotation because the declared return type `Validator` provides the context type. ## A combinator that returns a function Composition is itself a higher-order function: it takes validators and returns a validator. ```dart Validator all(List<Validator> rules) => (v) { for (final rule in rules) { final error = rule(v); if (error != null) return error; } return null; }; final password = all([notEmpty, minLength(8)]); void main() { print(password('')); // Required print(password('abc')); // At least 8 characters print(password('abcdefgh')); // null } ``` Key properties of this design: - **Order matters**: rules run in list order and the first failure wins, so put cheap and fundamental checks first. - **Short-circuiting** is explicit in the loop, so later rules never see input that already failed. - **Types flow through**: `password` is inferred as `Validator`, and a rule of the wrong shape in the list is a compile-time error. A further benefit is testability. Each rule is a plain function, so a unit test calls `minLength(3)('ab')` directly and compares the returned message, with no form, widget or framework involved. The combinator is tested the same way, by passing it small hand-written lambdas that record whether they ran, which pins down the ordering and short-circuit behaviour. Because nothing here holds hidden state, the same pipeline object can be shared safely by every field that needs it. ## Extension methods on a function type An extension can target a function type, including through its alias, which gives a fluent style without wrapper classes: ```dart extension ValidatorOps on Validator { Validator and(Validator next) => (v) => this.call(v) ?? next(v); } final username = notEmpty.and(minLength(3)); ``` Inside the extension, `this` is the function value, and `this.call(v)` invokes it. The `??` operator gives the same first-error-wins behaviour as the loop. ## Generic composition Nothing here is specific to strings. A generic helper composes any two compatible functions, and Dart infers `A`, `B` and `C` from the arguments: ```dart C Function(A) compose<A, B, C>(C Function(B) g, B Function(A) f) => (a) => g(f(a)); final trimmedLength = compose((String s) => s.length, (String s) => s.trim()); // inferred type: int Function(String) ``` ## Pitfalls | Pitfall | Consequence | Fix | |---|---|---| | Typing rules as `Function` | Every call is dynamic and unchecked | Use the `Validator` alias | | Building a new `all([...])` on every keystroke | Allocation churn, harder to test | Build once, store in a `final` | | Mutating the list passed to `all` later | Pipeline behaviour changes silently | Copy it with `List.of(rules)` or pass a const list | | Mixing `null` and `''` as success | Callers cannot tell valid from invalid | Reserve `null` for valid | The interview signal is fluency with the types themselves: writing the alias, the factory's return type, and a combinator whose output has the same type as its inputs, which is what makes the pieces snap together.

  • What is the full type of `minLength` in that pipeline, written without the alias?
    `String? Function(String) Function(int)`: a function that takes an `int` and returns a function from `String` to `String?`. The outermost function's parameter list is the last one, `(int)`; everything before `Function(int)` is its return type. Once a type returns another function type, an alias usually reads better.
  • How would you collect every error instead of stopping at the first?
    Give the combinator a different result type: `List<String> Function(String) collect(List<Validator> rules) => (v) => rules.map((r) => r(v)).nonNulls.toList();`. Each rule still has the `Validator` shape; only the combinator changes, running every rule and keeping the non-null messages.

saying these in an interview costs you the question

  • Validators have to be classes implementing a shared interface
  • A factory like minLength(8) runs the validation immediately
  • The combinator must return a bool, not another function
  • Lambdas inside the factory need explicit parameter types
  • Rule order inside the combinator never matters