Repository: tweag/nickel
Pull request #1975 (2024-06-21 17:00:33)

Split contracts into an immediate part and a delayed part
Depends on #1970 #1978.

Continuing the preliminary work toward a `one_of` combinator, it appeared that explicitly separating contracts into an immediate part (more or less a predicate) and a delayed part (the partial identity) is very compelling, both theoretically and semantically, and as a user interface. It makes the behavior of boolean contract combinators trivial to explain and to implement.

This PR thus changes both the documentation and the representation of general custom contracts to adhere to this vision: that is, a custom contract is now a pair `immediate: Option<RichTerm>, delayed: Option<RichTerm>`. A nice consequence is that the Rust implementation is simplified: instead of having a lot of ad-hoc cases (predicates, validators, custom, delayed) and corresponding primops, they can all be encoded as general custom contract using only one primop constructor `%contract/custom% : Option ImmediatePart -> Option DelayedPart -> Contract`. Their treatment is more uniform throughout the code base.

## Follow-up work

### Builtin contracts

Ideally, all builtin contracts - that is the one derived from records and from static types - would be represented this way, or at least have explicit immediate and delayed part.  This is the role of the new primop `contract/get_immediate` and `contract/get_delayed`. For custom contracts, they can just extract the corresponding component. The next step is that they also work on records and static types. However, this requires a non trivial rewrite of builtin contracts, and how they are generated from static types. While migrating builtin contracts to the new paradigm will be required to have boolean operators that work on them, it's not strictly necessary - for now, we keep them as naked functions, which still works. Because this PR is already big, we'll defer the migration of builtin contracts to follow-up work.

### Parametrized contracts

 Currently, the immediate part can only return `'Ok` (early success), `'Proceed` (success but apply delayed part) and `'Error` (early failure). This means that, to be able to apply another contract, one needs to implement it in the delayed part. For example, the Nullable contract.

The consequence is that although we could (we have all the required information) very much compute a purely immediate contract for `Nullable Number`, we currently can't. I think one possibility is to add a new return value for the immediate part, `'Apply contract`, which  would indicates to the contract system that the immediate part should proceed with a contract application. The immediate part of the resulting contract would thus be the (obvious) composition of both immediate parts, and we would have that `Nullable {foo | String}` has a delayed part, but `Nullable Number` doesn't.

This is left for future work as well.

dpulls[bot]: :tada: All dependencies have been resolved !
dpulls[bot]: :tada: All dependencies have been resolved !
yannham: >I didn't quite understand the part about needing an extra return variant for the immediate part. If we could destructure contracts back into immediate and delayed, couldn't we write Nullable like this?
>
>```
>let Nullable = fun Contract =>
>  std.contract.custom
>   (fun value =>
>      if value == null then
>        'Ok
>      else
>        (%contract/immediate% Contract) value
>    )
>    (%contract/delayed% Contract)
>```

I believe, in this case, that the delayed part of `Contract` will be applied in all situations. This is not what you want if `value == null` (for `Nullable {foo | Number}`, it doesn't make sense to try to map `Number` somewhere in a `null` value). I think we need to let the immediate part decide if it should short circuit and stops now, or proceed with the delayed part. The delayed part might assume that the immediate part must have run and thus doesn't need to be defensive; for `{foo | Number}`, it would assume without checks that what it gets is a record.
yannham: Oh, wait, were talking about the `'Apply` thingy?
jneem: Yes, exactly. (I was also initially confused about `'Ok` vs `'Proceed`, but reading the changes sorted that part out for me)
yannham: Then you're right, you don't technically need the `'Apply` variant. It's more of an API design question: would that make writing custom contracts nicer, more intuitive and less error prone?

```nickel
let Nullable = fun Contract =>
 std.contract.custom
  (fun value =>
     if value == null then
       'Ok
     else
       (%contract/immediate% Contract) value
   )
   (%contract/delayed% Contract)
```

versus

```nickel
let Nullable = fun Contract =>
  std.contract.parametrized (fun value
    if value == null then
      'Ok
    else
      'Apply {contract = Contract}
)
```

It's worth discussing the possibility, I think.


Patch:
diff --git a/core/src/eval/mod.rs b/core/src/eval/mod.rs
index d6347c8c..5609e2fe 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -73,7 +73,6 @@
 //! probably suboptimal for a functional language and is unable to collect cyclic data, which may
 //! appear inside recursive records. A dedicated garbage collector is probably something to
 //! consider at some point.
-
 use crate::{
     cache::{Cache as ImportCache, Envs, ImportResolver},
     closurize::{closurize_rec_record, Closurize},
diff --git a/core/src/eval/operation.rs b/core/src/eval/operation.rs
index 2ca3b7c7..24152248 100644
--- a/core/src/eval/operation.rs
+++ b/core/src/eval/operation.rs
@@ -26,6 +26,7 @@ use crate::{
     position::TermPos,
     serialize,
     serialize::ExportFormat,
+    stdlib,
     stdlib::internals,
     term::{
         array::{Array, ArrayAttrs, OutOfBoundError},
@@ -1195,64 +1196,6 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     _ => Err(mk_type_error!("label_push_diag", "Label")),
                 })
             }
-            UnaryOp::ContractFromPredicate => {
-                if matches!(&*t, Term::Fun(..) | Term::Match(_)) {
-                    Ok(Closure {
-                        body: RichTerm::new(
-                            Term::CustomContract(CustomContract::Predicate(RichTerm {
-                                term: t,
-                                pos,
-                            })),
-                            pos,
-                        ),
-                        env,
-                    })
-                } else {
-                    Err(mk_type_error!(
-                        "contract/from_predicate",
-                        "Function or MatchExpression"
-                    ))
-                }
-            }
-            UnaryOp::ContractFromValidator => {
-                if matches!(&*t, Term::Fun(..) | Term::Match(_)) {
-                    Ok(Closure {
-                        body: RichTerm::new(
-                            Term::CustomContract(CustomContract::Validator(RichTerm {
-                                term: t,
-                                pos,
-                            })),
-                            pos,
-                        ),
-                        env,
-                    })
-                } else {
-                    Err(mk_type_error!(
-                        "contract/from_validator",
-                        "Function or MatchExpression"
-                    ))
-                }
-            }
-            UnaryOp::ContractCustom => {
-                if matches!(&*t, Term::Fun(..) | Term::Match(_)) {
-                    Ok(Closure {
-                        body: RichTerm::new(
-                            Term::CustomContract(CustomContract::PartialIdentity(RichTerm {
-                                term: t,
-                                pos,
-                            })),
-                            pos,
-                        ),
-                        env,
-                    })
-                } else {
-                    Err(mk_type_error!(
-                        "contract/custom",
-                        "Function or MatchExpression"
-                    ))
-                }
-            }
-
             #[cfg(feature = "nix-experimental")]
             UnaryOp::EvalNix => {
                 if let Term::Str(s) = &*t {
@@ -1363,6 +1306,56 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     _ => Err(mk_type_error!("with_env", "Record")),
                 })
             }
+            UnaryOp::ContractGetImmediate => match &*t {
+                Term::CustomContract(CustomContract {
+                    immediate,
+                    delayed: _,
+                }) => Ok(Closure {
+                    body: immediate
+                        .as_ref()
+                        .cloned()
+                        .unwrap_or_else(|| Term::Null.into()),
+                    env,
+                }),
+                Term::Record(_) => Ok(Closure {
+                    body: mk_app!(
+                        stdlib::internals::record_immediate(),
+                        RichTerm { term: t, pos }
+                    )
+                    .with_pos(pos_op_inh),
+                    env,
+                }),
+                Term::Type(_) => unimplemented!("contract/get_immediate on Type"),
+                _ => Err(mk_type_error!(
+                    "contract/get_immediate",
+                    "CustomContract or Record"
+                )),
+            },
+            UnaryOp::ContractGetDelayed => match &*t {
+                Term::CustomContract(CustomContract {
+                    immediate: _,
+                    delayed,
+                }) => Ok(Closure {
+                    body: delayed
+                        .as_ref()
+                        .cloned()
+                        .unwrap_or_else(|| Term::Null.into()),
+                    env,
+                }),
+                Term::Record(_) => Ok(Closure {
+                    body: mk_app!(
+                        stdlib::internals::record_delayed(),
+                        RichTerm { term: t, pos }
+                    )
+                    .with_pos(pos_op_inh),
+                    env,
+                }),
+                Term::Type(_) => unimplemented!("contract/get_immediate on Type"),
+                _ => Err(mk_type_error!(
+                    "contract/get_delayed",
+                    "CustomContract or Record"
+                )),
+            },
         }
     }
 
@@ -1598,18 +1591,15 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             },
                             env: env1,
                         }),
-                        Term::CustomContract(CustomContract::PartialIdentity(ctr)) => Ok(Closure {
-                            body: ctr.clone(),
-                            env: env1,
-                        }),
-                        Term::CustomContract(CustomContract::Predicate(pred)) => Ok(Closure {
-                            body: mk_app!(internals::predicate_to_ctr(), pred.clone())
-                                .with_pos(pos1),
-                            env: env1,
-                        }),
-                        Term::CustomContract(CustomContract::Validator(validator)) => Ok(Closure {
-                            body: mk_app!(internals::validator_to_ctr(), validator.clone())
-                                .with_pos(pos1),
+                        Term::CustomContract(..) => Ok(Closure {
+                            body: mk_app!(
+                                internals::prepare_contract(),
+                                RichTerm {
+                                    term: t1,
+                                    pos: pos1
+                                }
+                            )
+                            .with_pos(pos1),
                             env: env1,
                         }),
                         Term::Record(..) => {
@@ -2722,6 +2712,52 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     pos_op_inh,
                 )))
             }
+            BinaryOp::ContractCustom => {
+                let immediate = match &*t1 {
+                    Term::Fun(..) | Term::Match(_) => Some(
+                        RichTerm {
+                            term: t1,
+                            pos: pos1,
+                        }
+                        .closurize(&mut self.cache, env1),
+                    ),
+                    Term::Null => None,
+                    _ => {
+                        return Err(mk_type_error!(
+                            "contract/custom",
+                            "Function or MatchExpression",
+                            1,
+                            t1,
+                            pos1
+                        ))
+                    }
+                };
+
+                let delayed = match &*t2 {
+                    Term::Fun(..) | Term::Match(_) => Some(
+                        RichTerm {
+                            term: t2,
+                            pos: pos2,
+                        }
+                        .closurize(&mut self.cache, env2),
+                    ),
+                    Term::Null => None,
+                    _ => {
+                        return Err(mk_type_error!(
+                            "contract/custom",
+                            "Function or MatchExpression",
+                            2,
+                            t2,
+                            pos2
+                        ))
+                    }
+                };
+
+                Ok(Closure::atomic_closure(RichTerm::new(
+                    Term::CustomContract(CustomContract { immediate, delayed }),
+                    pos_op_inh,
+                )))
+            }
         }
     }
 
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index c6d391df..9528817e 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -1081,9 +1081,6 @@ UOp: UnaryOp = {
     "label/go_codom" => UnaryOp::LabelGoCodom,
     "label/go_array" => UnaryOp::LabelGoArray,
     "label/go_dict" => UnaryOp::LabelGoDict,
-    "contract/from_predicate" => UnaryOp::ContractFromPredicate,
-    "contract/from_validator" => UnaryOp::ContractFromValidator,
-    "contract/custom" => UnaryOp::ContractCustom,
     "enum/embed" <Ident> => UnaryOp::EnumEmbed(<>),
     "array/map"  => UnaryOp::ArrayMap,
     "array/generate" => UnaryOp::ArrayGen,
@@ -1130,6 +1127,8 @@ UOp: UnaryOp = {
     "enum/make_variant" => UnaryOp::EnumMakeVariant,
     "enum/is_variant" => UnaryOp::EnumIsVariant,
     "enum/get_tag" => UnaryOp::EnumGetTag,
+    "contract/get_immediate" => UnaryOp::ContractGetImmediate,
+    "contract/get_delayed" => UnaryOp::ContractGetDelayed,
 }
 
 PatternGuard: RichTerm = "if" <Term> => <>;
@@ -1302,6 +1301,7 @@ BOpPre: BinaryOp = {
     "contract/apply" => BinaryOp::ContractApply,
     "contract/array_lazy_app" => BinaryOp::ContractArrayLazyApp,
     "contract/record_lazy_app" => BinaryOp::ContractRecordLazyApp,
+    "contract/custom" => BinaryOp::ContractCustom,
     "unseal" => BinaryOp::Unseal,
     "seal" => BinaryOp::Seal,
     "label/go_field" => BinaryOp::LabelGoField,
@@ -1517,9 +1517,9 @@ extern {
         "contract/apply" => Token::Normal(NormalToken::ContractApply),
         "contract/array_lazy_app" => Token::Normal(NormalToken::ContractArrayLazyApp),
         "contract/record_lazy_app" => Token::Normal(NormalToken::ContractRecordLazyApp),
-        "contract/from_predicate" => Token::Normal(NormalToken::ContractFromPredicate),
-        "contract/from_validator" => Token::Normal(NormalToken::ContractFromValidator),
         "contract/custom" => Token::Normal(NormalToken::ContractCustom),
+        "contract/get_immediate" => Token::Normal(NormalToken::ContractGetImmediate),
+        "contract/get_delayed" => Token::Normal(NormalToken::ContractGetDelayed),
         "op force" => Token::Normal(NormalToken::OpForce),
         "blame" => Token::Normal(NormalToken::Blame),
         "label/flip_polarity" => Token::Normal(NormalToken::LabelFlipPol),
diff --git a/core/src/parser/lexer.rs b/core/src/parser/lexer.rs
index e8d7c8d1..f8eed2e7 100644
--- a/core/src/parser/lexer.rs
+++ b/core/src/parser/lexer.rs
@@ -200,12 +200,12 @@ pub enum NormalToken<'input> {
     ContractArrayLazyApp,
     #[token("%contract/record_lazy_apply%")]
     ContractRecordLazyApp,
-    #[token("%contract/from_predicate%")]
-    ContractFromPredicate,
-    #[token("%contract/from_validator%")]
-    ContractFromValidator,
     #[token("%contract/custom%")]
     ContractCustom,
+    #[token("%contract/get_immediate%")]
+    ContractGetImmediate,
+    #[token("%contract/get_delayed%")]
+    ContractGetDelayed,
     #[token("%blame%")]
     Blame,
     #[token("%label/flip_polarity%")]
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 2bb5175e..14aad074 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -821,24 +821,28 @@ where
             Str(v) => allocator.escaped_string(v).double_quotes(),
             StrChunks(chunks) => allocator.chunks(chunks, StringRenderStyle::Multiline),
             Fun(id, body) => allocator.function(allocator.as_string(id), body),
-            // Format this as the primop application `<custom contract constructor> <contract impl>`.
-            CustomContract(contract_node) => {
-                let (constructor, contract) = match contract_node {
-                    ContractNode::Predicate(p) => ("%contract/from_predicate%", p),
-                    ContractNode::Validator(v) => ("%contract/from_validator%", v),
-                    ContractNode::PartialIdentity(pid) => ("%contract/custom%", pid),
-                };
-
-                docs![
-                    allocator,
-                    constructor,
+            // Format this as the primop application
+            // `<custom contract constructor> <immediate> <delayed>`.
+            CustomContract(ContractNode { immediate, delayed }) => {
+                let arg_or_null = |arg: &Option<RichTerm>| {
                     docs![
                         allocator,
                         allocator.line(),
-                        contract.pretty(allocator).parens()
+                        if let Some(arg) = arg {
+                            allocator.atom(arg)
+                        } else {
+                            allocator.text("null")
+                        }
                     ]
                     .nest(2)
                     .group()
+                };
+
+                docs![
+                    allocator,
+                    "%contract/custom%",
+                    arg_or_null(immediate),
+                    arg_or_null(delayed)
                 ]
             }
             FunPattern(pat, body) => allocator.function(allocator.pat_with_parens(pat), body),
diff --git a/core/src/stdlib.rs b/core/src/stdlib.rs
index ed4e6a5d..e4f7d9d0 100644
--- a/core/src/stdlib.rs
+++ b/core/src/stdlib.rs
@@ -102,8 +102,9 @@ pub mod internals {
 
     generate_accessor!(stdlib_contract_equal);
 
-    generate_accessor!(predicate_to_ctr);
-    generate_accessor!(validator_to_ctr);
+    generate_accessor!(prepare_contract);
+    generate_accessor!(record_immediate);
+    generate_accessor!(record_delayed);
 
     generate_accessor!(rec_default);
     generate_accessor!(rec_force);
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index dfdfa723..78cc7bd2 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -216,24 +216,8 @@ pub enum Term {
     #[serde(skip)]
     Type(Type),
 
-    /// A custom contract built.
-    ///
-    /// Custom contracts can be partial identities (the most general form, which either blame or
-    /// return the value with potential delayed checks buried inside), predicates or validator (see
-    /// [CustomContract].
-    ///
-    /// Partial identity are built using `std.contract.custom`. Note that, for backward
-    /// compatibility, users can also use naked functions ([Term::Fun]) for partial identities
-    /// instead. But this is discouraged and will be deprecated in the future. Indeed, custom
-    /// contracts aren't supposed to be applied using the standard function application, because we
-    /// need to perform additional bookkeeping upon application, so there's no good reason to
-    /// represent them as naked functions.
-    ///
-    /// Having a separate node lets us leverage the additional information for example to implement
-    /// a restricted `or` combinator on contracts, which needs to know which contracts support
-    /// booleans operations (predicates and validators), or for better error messages in the future
-    /// when parametric contracts aren't fully applied
-    /// ([#1460](https://github.com/tweag/nickel/issues/1460)).
+    /// A custom contract. See the documentation of [CustomContract] for more details on the split
+    /// immediate/delayed representation.
     #[serde(skip)]
     CustomContract(CustomContract),
 
@@ -382,36 +366,44 @@ pub enum BindingType {
     Revertible(FieldDeps),
 }
 
-/// A term representing a custom contract.
+/// A custom contract.
 ///
-/// This term doesn't currently necessarily include all generic custom contracts occurring in a
-/// program (functions `Label -> Dyn -> Dyn`). For backward compatibility reasons, we need to
-/// support naked functions - custom contracts that don't use the corresponding
-/// `std.contract.custom` constructor.
+/// Having a separate node for custom contracts lets us leverage the additional information for
+/// example to implement a restricted `or` combinator on contracts, which needs to know which
+/// contracts support booleans operations (predicates and validators), or for better error messages
+/// in the future when parametric contracts aren't fully applied
+/// ([#1460](https://github.com/tweag/nickel/issues/1460)).
 ///
-/// In the future, we want to have all custom contracts represented as [CustomContract]s, requiring
-/// the use of a dedicated constructor: `std.contract.custom`, `std.contract.from_record`,
-/// etc in user code. The requirement of these dedicated constructors is unfortunately a breaking
-/// change for existing custom contracts previously written as naked functions. Using naked
-/// functions is discouraged and will be deprecated in the future.
+/// # Immediate and delayed contracts
 ///
-/// In the meantime, we can put _some_ contracts here without breaking things (the one that are
-/// already built using a special constructor, such as `std.contract.from_predicate`). Maintaining
-/// those additional data (if a contract came from `from_predicate` or is a naked function) is
-/// useful for implementing some contract operations, such as the `or` combinator, or provide
-/// better error messages in some situations.
+/// Custom contracts have two parts: an immediate part and a delayed part.
+///
+/// The immediate part is similar to a predicate or a validator: this is a function that takes a
+/// value and return either `'Ok`, `'Proceed` or `'Error {..}`. The immediate part gather the
+/// checks that can be done eagerly, without forcing the value (the immediate part can actually
+/// force the value, but it's up to the implementer to decide - for builtin contracts, the
+/// immediate part never forces values)
+///
+/// The delayed part is a partial identity which takes a label and the value and either blames or
+/// return the value with potential delayed checks buried inside.
+///
+/// Each part is optional. If the part isn't set, it's considered to be an always-accepting
+/// function: that is, an immediate part sets to `None` is equivalent to `Some (fun _ => 'Proceed)`
+/// and a delayed part set to `None` is equivalent to `Some (fun label value => value)`.
+///
+/// # Naked functions as custom contracts
+///
+/// Nowadays, using dedicated constructors is the only documented way of creating custom contracts:
+/// `std.contract.custom`, `std.contract.from_record`, etc. The requirement to use those dedicated
+/// constructors is unfortunately a breaking change (prior to Nickel 1.8) as custom contracts were
+/// previously written as naked functions. Using naked functions is discouraged and will be
+/// deprecated in the future.
 #[derive(Debug, Clone, Serialize, Deserialize)]
-pub enum CustomContract {
-    /// A contract built from a predicate. The argument is a function of type
-    /// `Dyn -> Bool`.
-    Predicate(RichTerm),
-    /// A contract built from a validator. A validator is a function of type `Dyn -> [| 'Ok, 'Error
-    /// ReifiedLabel |]` where `ReifiedLabel` is a record with error reporting data (a message,
-    /// notes, etc.)
-    Validator(RichTerm),
-    /// A generic custom contract, represented as a partial identity function of type `Label -> Dyn
-    /// -> Dyn`.
-    PartialIdentity(RichTerm),
+pub struct CustomContract {
+    /// The immediate part of the contract.
+    pub immediate: Option<RichTerm>,
+    /// The delayed part of the contract.
+    pub delayed: Option<RichTerm>,
 }
 
 /// A runtime representation of a contract, as a term and a label ready to be applied via
@@ -1310,18 +1302,6 @@ pub enum UnaryOp {
     /// See `GoDom`.
     LabelGoDict,
 
-    /// Wrap a predicate function as a [CustomContract]. You can think of this primop as
-    /// a type constructor for custom contracts.
-    ContractFromPredicate,
-
-    /// Wrap a validator function as a [CustomContract]. You can think of this primop as a type
-    /// constructor for custom contracts.
-    ContractFromValidator,
-
-    /// Wrap a partial identity function as a [CustomContract]. You can think of this primop as a
-    /// type constructor for contracts.
-    ContractCustom,
-
     /// Force the evaluation of its argument and proceed with the second.
     Seq,
 
@@ -1509,6 +1489,12 @@ pub enum UnaryOp {
     /// a record, for example). This is why the name tries to make it clear that it shouldn't be
     /// used blindly for something else.
     PatternBranch,
+
+    /// Extract the immediate part of a contract.
+    ContractGetImmediate,
+
+    /// Extract the delayed part of a contract.
+    ContractGetDelayed,
 }
 
 impl fmt::Display for UnaryOp {
@@ -1532,9 +1518,6 @@ impl fmt::Display for UnaryOp {
             LabelGoCodom => write!(f, "label/go_codom"),
             LabelGoArray => write!(f, "label/go_array"),
             LabelGoDict => write!(f, "label/go_dict"),
-            ContractFromPredicate => write!(f, "contract/from_predicate"),
-            ContractFromValidator => write!(f, "contract/from_validator"),
-            ContractCustom => write!(f, "contract/custom"),
             Seq => write!(f, "seq"),
             DeepSeq => write!(f, "deep_seq"),
             ArrayLength => write!(f, "array/length"),
@@ -1573,6 +1556,8 @@ impl fmt::Display for UnaryOp {
             EnumGetTag => write!(f, "enum/get_tag"),
 
             PatternBranch => write!(f, "pattern_branch"),
+            ContractGetImmediate => write!(f, "contract/get_immediate"),
+            ContractGetDelayed => write!(f, "contract/get_delayed"),
         }
     }
 }
@@ -1806,6 +1791,10 @@ pub enum BinaryOp {
     /// Look up the [`crate::label::TypeVarData`] associated with a [`SealingKey`] in the type
     /// environment of a [label](Term::Lbl)
     LabelLookupTypeVar,
+
+    /// Wrap a partial identity function as a [CustomContract]. You can think of this primop as a
+    /// type constructor for contracts.
+    ContractCustom,
 }
 
 impl BinaryOp {
@@ -1872,6 +1861,7 @@ impl fmt::Display for BinaryOp {
             Seal => write!(f, "seal"),
             ContractArrayLazyApp => write!(f, "contract/array_lazy_apply"),
             ContractRecordLazyApp => write!(f, "contract/record_lazy_apply"),
+            ContractCustom => write!(f, "contract/custom"),
             LabelWithMessage => write!(f, "label/with_message"),
             LabelWithNotes => write!(f, "label/with_notes"),
             LabelAppendNote => write!(f, "label/append_note"),
@@ -2141,22 +2131,15 @@ impl Traverse<RichTerm> for RichTerm {
                 let t = t.traverse(f, order)?;
                 RichTerm::new(Term::FunPattern(pat, t), pos)
             }
-            Term::CustomContract(CustomContract::Predicate(t)) => {
-                let t = t.traverse(f, order)?;
-                RichTerm::new(Term::CustomContract(CustomContract::Predicate(t)), pos)
-            }
-            Term::CustomContract(CustomContract::Validator(t)) => {
-                let t = t.traverse(f, order)?;
-                RichTerm::new(Term::CustomContract(CustomContract::Validator(t)), pos)
-            }
-            Term::CustomContract(CustomContract::PartialIdentity(t)) => {
-                let t = t.traverse(f, order)?;
+            Term::CustomContract(CustomContract { immediate, delayed }) => {
+                let immediate = immediate.map(|t| t.traverse(f, order)).transpose()?;
+                let delayed = delayed.map(|t| t.traverse(f, order)).transpose()?;
+
                 RichTerm::new(
-                    Term::CustomContract(CustomContract::PartialIdentity(t)),
+                    Term::CustomContract(CustomContract { immediate, delayed }),
                     pos,
                 )
             }
-
             Term::Let(id, t1, t2, attrs) => {
                 let t1 = t1.traverse(f, order)?;
                 let t2 = t2.traverse(f, order)?;
@@ -2347,12 +2330,13 @@ impl Traverse<RichTerm> for RichTerm {
             }),
             Term::Fun(_, t)
             | Term::FunPattern(_, t)
-            | Term::CustomContract(CustomContract::Predicate(t))
-            | Term::CustomContract(CustomContract::Validator(t))
-            | Term::CustomContract(CustomContract::PartialIdentity(t))
             | Term::EnumVariant { arg: t, .. }
             | Term::Op1(_, t)
             | Term::Sealed(_, t, _) => t.traverse_ref(f, state),
+            Term::CustomContract(CustomContract { immediate, delayed }) => immediate
+                .as_ref()
+                .and_then(|t| t.traverse_ref(f, state))
+                .or_else(|| delayed.as_ref().and_then(|t| t.traverse_ref(f, state))),
             Term::Let(_, t1, t2, _)
             | Term::LetPattern(_, t1, t2)
             | Term::App(t1, t2)
diff --git a/core/src/transform/free_vars.rs b/core/src/transform/free_vars.rs
index 06a687eb..2b3beedd 100644
--- a/core/src/transform/free_vars.rs
+++ b/core/src/transform/free_vars.rs
@@ -8,7 +8,7 @@ use crate::{
     term::pattern::*,
     term::{
         record::{Field, FieldDeps, RecordDeps},
-        CustomContract, IndexMap, MatchBranch, RichTerm, SharedTerm, StrChunk, Term,
+        IndexMap, MatchBranch, RichTerm, SharedTerm, StrChunk, Term,
     },
     typ::{RecordRowF, RecordRows, RecordRowsF, Type, TypeF},
 };
@@ -106,14 +106,18 @@ impl CollectFreeVars for RichTerm {
                     free_vars.extend(fresh);
                 }
             }
-            Term::Op1(_, t)
-            | Term::Sealed(_, t, _)
-            | Term::EnumVariant { arg: t, .. }
-            | Term::CustomContract(CustomContract::Predicate(t))
-            | Term::CustomContract(CustomContract::Validator(t))
-            | Term::CustomContract(CustomContract::PartialIdentity(t)) => {
+            Term::Op1(_, t) | Term::Sealed(_, t, _) | Term::EnumVariant { arg: t, .. } => {
                 t.collect_free_vars(free_vars)
             }
+            Term::CustomContract(custom_contract) => {
+                if let Some(ref mut immediate) = custom_contract.immediate {
+                    immediate.collect_free_vars(free_vars);
+                }
+
+                if let Some(ref mut delayed) = custom_contract.delayed {
+                    delayed.collect_free_vars(free_vars);
+                }
+            }
             Term::OpN(_, ts) => {
                 for t in ts {
                     t.collect_free_vars(free_vars);
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index 69466d63..4c058342 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -1599,10 +1599,18 @@ fn walk<V: TypecheckVisitor>(
         }
         Term::EnumVariant { arg: t, ..}
         | Term::Sealed(_, t, _)
-        | Term::Op1(_, t)
-        | Term::CustomContract(CustomContract::Predicate(t))
-        | Term::CustomContract(CustomContract::Validator(t))
-        | Term::CustomContract(CustomContract::PartialIdentity(t)) => walk(state, ctxt.clone(), visitor, t),
+        | Term::Op1(_, t) => walk(state, ctxt, visitor, t),
+        Term::CustomContract(CustomContract {immediate, delayed }) => {
+            if let Some(immediate) = immediate {
+                walk(state, ctxt.clone(), visitor, immediate)?;
+            }
+
+            if let Some(delayed) = delayed {
+                walk(state, ctxt, visitor, delayed)?;
+            }
+
+            Ok(())
+        }
         Term::Op2(_, t1, t2) => {
             walk(state, ctxt.clone(), visitor, t1)?;
             walk(state, ctxt, visitor, t2)
@@ -1907,65 +1915,45 @@ fn check<V: TypecheckVisitor>(
                 .map_err(|err| err.into_typecheck_err(state, rt.pos))?;
             check(state, ctxt, visitor, t, trg)
         }
-        // [^predicate-is-check]: [crate::term::Contract::Predicate] isn't supposed to be used in
-        // Nickel source code directly, but we can typecheck it. A predicate is a thin wrapper
-        // around a specific function class (of type `Dyn -> Bool`), which turns it into a generic
-        // custom contract (currently represented as `Dyn`).
+        // [^custom-contract-is-check]: [crate::term::CustomContract] isn't supposed to be used in
+        // Nickel source code directly, but we can typecheck it. A custom contract is a
+        // datastructure holding two (optional) functions: the immediate part and the delayed part.
         //
-        // One can see it as the application of a `from_predicate` constructor:
-        // `%contract/from_predicate% <t>`.
+        // One can see it as the application of a `custom` constructor:
+        // `%contract/custom% immediate delayed`.
         //
         // It's thus not entirely obvious if this should be a checking or a infer rule: if seen as
         // a simple function application, it should be infer (it's an elimination rule). If seen as
         // a type constructor, it should be check (it's an introduction rule). We pick the last
-        // interpretation, because although `Predicate` is built from an application of
-        // `from_predicate`, it's not the same thing - in particular [Contract::Predicate] has been
-        // reduced to a (weak head) normal form, and put in a separate node. Thus we lean toward a
-        // check rule.
+        // interpretation, because although a custom contract is built from an application of
+        // `%contract/custom%`, it's not the same thing - in particular both components of a custom
+        // contract have been reduced to a (weak head) normal form and put in a separate node. Thus
+        // we lean toward a check rule.
         //
         // Additionally, because this rule can't produce a polymorphic type (it produces a `Dyn`,
         // or morally a `Contract` type, if we had one), we don't lose anything by making it a
         // check rule, as for e.g. literals.
-        Term::CustomContract(CustomContract::Predicate(t)) => {
+        Term::CustomContract(CustomContract { immediate, delayed }) => {
             // The overall type of a custom contract is currently `Dyn`, as we don't have a better
             // one.
             ty.unify(mk_uniftype::dynamic(), state, &ctxt)
                 .map_err(|err| err.into_typecheck_err(state, rt.pos))?;
 
-            // A predicate must be of type `Dyn -> Bool`.
-            check(
-                state,
-                ctxt,
-                visitor,
-                t,
-                mk_uniftype::arrow(mk_uniftype::dynamic(), mk_uniftype::bool()),
-            )
-        }
-        Term::CustomContract(CustomContract::Validator(t)) => {
-            // The overall type of a custom contract is currently `Dyn`, as we don't have a better
-            // one.
-            ty.unify(mk_uniftype::dynamic(), state, &ctxt)
-                .map_err(|err| err.into_typecheck_err(state, rt.pos))?;
-
-            check(state, ctxt, visitor, t, operation::validator_type())
-        }
-        // See [^predicate-is-check]. We took `Predicate` as an example, but this reasoning applies
-        // to other kind of custom contracts, such as `PartialIdentity`.
-        Term::CustomContract(CustomContract::PartialIdentity(t)) => {
-            // The overall type of a custom contract is currently `Dyn`, as we don't have a better
-            // one.
-            ty.unify(mk_uniftype::dynamic(), state, &ctxt)
-                .map_err(|err| err.into_typecheck_err(state, rt.pos))?;
+            if let Some(immediate) = immediate {
+                check(
+                    state,
+                    ctxt.clone(),
+                    visitor,
+                    immediate,
+                    operation::immediate_type(),
+                )?;
+            }
 
-            // The type of the implementation of a custom contract is `Label -> Dyn -> Dyn` (but
-            // remember that we don't actually have a `Label` type).
-            let partial_id_type = mk_uty_arrow!(
-                mk_uniftype::dynamic(),
-                mk_uniftype::dynamic(),
-                mk_uniftype::dynamic()
-            );
+            if let Some(delayed) = delayed {
+                check(state, ctxt, visitor, delayed, operation::delayed_type())?;
+            }
 
-            check(state, ctxt, visitor, t, partial_id_type)
+            Ok(())
         }
         Term::Array(terms, _) => {
             let ty_elts = state.table.fresh_type_uvar(ctxt.var_level);
diff --git a/core/src/typecheck/operation.rs b/core/src/typecheck/operation.rs
index 2f8946b3..3d04d2bb 100644
--- a/core/src/typecheck/operation.rs
+++ b/core/src/typecheck/operation.rs
@@ -79,26 +79,6 @@ pub fn get_uop_type(
         | UnaryOp::LabelGoCodom
         | UnaryOp::LabelGoArray
         | UnaryOp::LabelGoDict => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
-        // Morally returns a contract, but we don't have a proper type for that yet
-        // (Dyn -> Bool) -> Dyn
-        UnaryOp::ContractFromPredicate => (
-            mk_uty_arrow!(mk_uniftype::dynamic(), mk_uniftype::bool()),
-            mk_uniftype::dynamic(),
-        ),
-        // Morally returns a contract, but we don't have a proper type for that yet
-        // <validator_type()> -> Dyn (see `validator_type()` below for more details)
-        UnaryOp::ContractFromValidator => (validator_type(), mk_uniftype::dynamic()),
-        // Morally returns a contract, but we don't have a proper type for that yet
-        // (Dyn -> Dyn -> Bool) -> Dyn
-        UnaryOp::ContractCustom => (
-            mk_uty_arrow!(
-                mk_uniftype::dynamic(),
-                mk_uniftype::dynamic(),
-                mk_uniftype::dynamic()
-            ),
-            mk_uniftype::dynamic(),
-        ),
-
         // forall rows a. { id: a | rows} -> a
         UnaryOp::RecordAccess(id) => {
             let rows = state.table.fresh_rrows_uvar(var_level);
@@ -290,6 +270,12 @@ pub fn get_uop_type(
                 mk_uty_arrow!(mk_uniftype::dynamic(), mk_uniftype::dynamic()),
             )
         }
+        // Morally, we return <immediate_type()> OR null, but we can't represent that safely.
+        // Dyn -> Dyn
+        UnaryOp::ContractGetImmediate => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
+        // Morally, we return <delayed_type()> OR null, but we can't represent that safely.
+        // Dyn -> Dyn
+        UnaryOp::ContractGetDelayed => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
     })
 }
 
@@ -319,6 +305,11 @@ pub fn get_bop_type(
             mk_uniftype::dynamic(),
             mk_uty_arrow!(mk_uniftype::dynamic(), mk_uniftype::dynamic()),
         ),
+        // In practice, this operator can alternatively be given `null` values for both arguments.
+        // However, this isn't representable in the current type system, so in typed code, this
+        // can't be done.
+        // <immediate_type()> -> <delayed_type()> -> Dyn
+        BinaryOp::ContractCustom => (immediate_type(), delayed_type(), mk_uniftype::dynamic()),
         // Sym -> Dyn -> Dyn -> Dyn
         BinaryOp::Unseal => (
             mk_uniftype::sym(),
@@ -593,23 +584,26 @@ pub fn get_nop_type(
     })
 }
 
-/// Return the type of a validator, which is one way of representing a custom contract. This static
-/// type is more rigid than the actual values accepted by `std.contract.from_validator`, because we
-/// can't represent optional fields in the type system. But it's ok to be stricter in statically
-/// typed code.
+/// Returns the type of a the immediate part of a custom contract. This static type is more rigid
+/// than the actual values accepted by `std.contract.custom`, because we can't represent
+/// optional fields in the type system. But it's ok to be stricter in statically typed code.
 ///
 /// Also remember that custom contracts shouldn't appear directly in the source code of Nickel:
 /// they are built using `std.contract.from_xxx` and `std.contract.custom` functions. We implement
 /// typechecking for them mostly because we can (to avoid an `unimplemented!` or a `panic!`), but
 /// we don't expect this case to trigger at the moment, so it isn't of the utmost importance.
 ///
-/// The result represents the type `Dyn -> [| 'Ok, 'Error { message: String, notes: Array
-/// String } |]`.
-pub fn validator_type() -> UnifType {
+/// The result represents the type:
+///
+/// ```nickel
+/// Dyn -> [| 'Ok, 'Proceed, 'Error { message: String, notes: Array String } |]
+/// ```
+pub fn immediate_type() -> UnifType {
     mk_uty_arrow!(
         mk_uniftype::dynamic(),
         mk_uty_enum!(
             "Ok",
+            "Proceed",
             (
                 "Error",
                 mk_uty_record!(
@@ -620,3 +614,13 @@ pub fn validator_type() -> UnifType {
         )
     )
 }
+
+/// Returns the type of the delayed part of a custom contract, which is currently just `Dyn -> Dyn
+/// -> Dyn` (take a label and return a partial identity). See [immediate_type] for more details.
+pub fn delayed_type() -> UnifType {
+    mk_uty_arrow!(
+        mk_uniftype::dynamic(),
+        mk_uniftype::dynamic(),
+        mk_uniftype::dynamic()
+    )
+}
diff --git a/core/stdlib/internals.ncl b/core/stdlib/internals.ncl
index c11546df..ff99d391 100644
--- a/core/stdlib/internals.ncl
+++ b/core/stdlib/internals.ncl
@@ -196,14 +196,14 @@
     else
       %blame% (%label/with_message% "not a record" label),
 
-  # Lazy dictionary contract for `{_ | T}`
+  # Delayed dictionary contract for `{_ | T}`
   "$dict_contract" = fun contract label value =>
     if %typeof% value == 'Record then
       %contract/record_lazy_apply% (%label/go_dict% label) value (fun _field => contract)
     else
       %blame% (%label/with_message% "not a record" label),
 
-  # Eager dictionary contract for `{_ : T}`
+  # Immediate dictionary contract for `{_ : T}`
   "$dict_type" = fun contract label value =>
     if %typeof% value == 'Record then
       %record/map%
@@ -323,6 +323,70 @@
           )
     },
 
+  # Take a custom contract split as an immediate and delayed part, both of which
+  # can be null, and transform it into a partial identity function that can be
+  # given to %contract/apply%.
+  "$prepare_contract" = fun contract label value =>
+    let immediate = %contract/get_immediate% contract in
+    let delayed = %contract/get_delayed% contract in
+
+    if immediate != null then
+      let result = immediate value in
+
+      # In user code, we would have used a match construct here, which would be
+      # more readable. However, internal functions are core utilities that get
+      # called quite a lot (and internals should be fairly reduced), so we try
+      # to use lower-level constructs to reclaim a tad more performance instead.
+      if result == 'Ok then
+        value
+      else if result == 'Proceed then
+        if delayed != null then
+          delayed label value
+        else
+          value
+      else if %typeof% result == 'Enum && %enum/get_tag% result == 'Error then
+        let payload = %enum/get_arg% result in
+
+        let label =
+          if %record/field_is_defined% "message" payload
+          && std.is_string payload.message then
+            %label/with_message% payload.message label
+          else
+            label
+        in
+
+        let label =
+          if %record/field_is_defined% "notes" payload
+          && std.is_array payload.notes then
+            %label/with_notes% (%force% payload.notes) label
+          else
+            label
+        in
+
+        %blame% label
+      else
+        %blame%
+          (
+            %label/with_notes%
+              (
+                %force%
+                  [
+                    "The immediate part of this contract returned an invalid result (which must be either `'Ok`, `'Proceed`  or `'Error {..}`)",
+                    "Please check the implementation of the contract that has been broken"
+                  ]
+              )
+              label
+          )
+    else if delayed != null then
+      delayed label value
+    else
+      value,
+
+  # Those are placeholder for future implementation. Right now they aren't used
+  # yet.
+  "$record_immediate" = fun _ _ => 'Proceed,
+  "$record_delayed" = fun _ _ value => value,
+
   # Recursive priorities operators
 
   "$rec_force" = fun value => %rec_force% (%force% value),
diff --git a/core/stdlib/std.ncl b/core/stdlib/std.ncl
index 022240ea..969c1cf2 100644
--- a/core/stdlib/std.ncl
+++ b/core/stdlib/std.ncl
@@ -60,7 +60,7 @@
           ```
         "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'Array then
@@ -70,7 +70,8 @@
                   'Error { message = "empty array" }
               else
                 'Error { message = "not a array" }
-          ),
+          )
+          null,
 
     first
       : forall a. Array a -> a
@@ -822,7 +823,7 @@
   contract = {
     Equal
       | doc m%"
-        `Equal` is a contract enforcing equality to a given constant.
+        `Equal` is a delayed contract enforcing equality to a given constant.
 
         `Equal` is lazy over arrays and records. When checking such values,
         `Equal` doesn't test for equality of elements right away (it just tests
@@ -1006,58 +1007,107 @@
       = fun message label => %blame% (%label/with_message% message label),
 
     custom
-      : (Dyn -> Dyn -> Dyn) -> Dyn
+      | (
+        Dyn -> [|
+          'Ok,
+          'Proceed,
+          'Error { message | String | optional, notes | Array String | optional }
+        |]
+      ) -> (Dyn -> Dyn -> Dyn) -> Dyn
       | doc m%"
-          Build a generic custom contract.
+          Build a generic custom contract from an immediate part and a delayed
+          part.
 
-          # Custom contract
+          # Immediate vs delayed
 
-          In general, you should use more specific contract constructors
-          whenever possible, such as `std.contract.from_predicate` or
+          In general, you should write immediate contracts whenever possible,
+          using for example `std.contract.from_predicate` or
           `std.contract.from_validator`, as they are easier to write and support
           more operations.
 
           However, those specialized constructors might not cover all your
-          needs, in particular when implementing lazy contracts (such as
+          needs, in particular when implementing delayed contracts (such as
           contracts operating on functions or data structures like records and
-          arrays) or contracts that might take other lazy contracts as
+          arrays) or contracts that might take other delayed contracts as
           parameters and apply them (using `std.contract.apply`).
 
           Custom contracts built using `std.contract.custom` are the most
-          general form of custom contracts. The implementation given as an
-          argument must be a function taking a label and a value as parameters,
-          and either blame the label or return the original value with potential
-          delayed checks pushed inside. For more details on custom contracts,
-          please refer to the corresponding section of the user manual.
+          general form of custom contracts. They consist of an immediate part
+          and a delayed part.
+
+          ## Immediate part
+
+          The immediate part is a function that takes the checked value as a
+          parameter and returns either:
+
+          - `'Ok` indicating that the whole contract should succeed immediately
+            without running the delayed part
+          - `'Proceed` indicating that the immediate part has succeeded but that
+            we should still proceed with the delayed part
+          - `'Error {..}` indicating that the contract should fail with provided
+            error information.
+
+          The immediate part is thus similar to a validator, excepted that they
+          can return the additional `'Proceed` value (see
+          `std.contract.from_validator`).
+
+          ## Delayed part
+
+          The delayed part must be a function taking a label and a value as
+          parameters, and either blame the label or return the original value
+          with potential delayed checks pushed inside.
+
+          For more details on custom contracts and delayed contracts, please
+          refer to the corresponding section of the user manual.
 
           # Typing
 
           Because Nickel doesn't currently have proper types for labels and
           contracts, they are replaced with `Dyn` in the type signature. You can
-          think of the actual type of `custom` as `(Label -> Dyn -> Dyn) ->
-          Contract`.
+          think of the actual type of the delayed part as `(Label -> Dyn ->
+          Dyn)`, and the return type of `custom` as `Contract`.
 
           # Examples
 
           ```nickel
           let Nullable = fun Contract =>
-            std.contract.custom (fun label value =>
-              if value == null then
-                value
-              else
-                std.contract.apply Contract label value
-            )
+            std.contract.custom
+              (fun value =>
+                if value == null then
+                  'Ok
+                else
+                  'Proceed
+              )
+              (std.contract.apply Contract)
           in
           null | Nullable Number
             => null
           ```
         "%
-      = fun custom => %contract/custom% custom,
+      = fun immediate delayed => %contract/custom% immediate delayed,
+
+    delayed
+      | (Dyn -> Dyn -> Dyn) -> Dyn
+      | doc m%"
+          Build a generic delayed custom contract.
+
+          `std.contract.delayed contract_impl` is a shortcut for
+
+          ```nickel
+          std.contract.custom
+            (fun _ => 'Proceed)
+            contract_impl
+          ```
+
+          Please refer to the documentation of `std.contract.custom` for more
+          details.
+        "%
+      = fun delayed => %contract/custom% null delayed,
 
     from_predicate
-      : (Dyn -> Bool) -> Dyn
+      | (Dyn -> Bool) -> Dyn
       | doc m%"
-          Build a custom contract from a boolean predicate.
+          Build an immediate custom contract from a boolean predicate.
 
           # Contract application
 
@@ -1088,7 +1138,16 @@
           0 | IsZero
           ```
         "%
-      = fun pred => %contract/from_predicate% pred,
+      = fun pred =>
+        %contract/custom%
+          (
+            fun value =>
+              if pred value then
+                'Ok
+              else
+                'Error {}
+          )
+          null,
 
     from_validator
       | (
@@ -1098,18 +1157,16 @@
         |]
       ) -> Dyn
       | doc m%%"
-          Build a custom contract from a validator.
+          Build an immediate custom contract from a validator.
 
           # Validator
 
-          A validator is a function returning either `'Ok` or `'Error
-          {message, notes}` with an optional error message and notes to
-          display. A validator is similar to a predicate (see
-          `std.contract.from_predicate`) as both are contract implementations
-          that must decide right away if the value passes or fails (that is,
-          those contracts aren't lazy). But as opposed to predicates,
-          validators have the additional ability to customize the error
-          reporting.
+          A validator is a function returning either `'Ok` or `'Error {message,
+          notes}` with an optional error message and notes to display. Both
+          validators and predicates are immediate contracts: they must decide
+          right away if the value passes or fails (as opposed to delayed
+          contracts). But as opposed to predicates, validators have the
+          additional ability to customize the error reporting.
 
           # Typing
 
@@ -1136,7 +1193,7 @@
           0 | IsZero
           ```
         "%%
-      = fun validator => %contract/from_validator% validator,
+      = fun validator => %contract/custom% validator null,
 
     Sequence
       | doc m%"
@@ -1228,8 +1285,14 @@
               ```nickel
               let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
 
-              let Contract = std.contract.custom (fun label value =>
-                if std.is_number value then
+              let Contract = std.contract.custom
+                (fun value =>
+                  if std.is_number value then
+                    'Proceed
+                  else
+                    'Ok
+                )
+                (fun label value =>
                   std.contract.apply
                     ContractNum
                     (std.contract.label.with_message
@@ -1237,9 +1300,7 @@
                       label
                     )
                     value
-                else
-                  value
-              )
+                )
               in
 
               5 | Contract
@@ -1270,8 +1331,14 @@
               ```nickel
               let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
 
-              let Contract = std.contract.custom (fun label value =>
-                if std.is_number value then
+              let Contract = std.contract.custom
+                (fun value =>
+                  if std.is_number value then
+                    'Proceed
+                  else
+                    'Ok
+                )
+                (fun label value =>
                   std.contract.apply
                     ContractNum
                     (label
@@ -1282,9 +1349,7 @@
                         ]
                     )
                     value
-                else
-                  value
-              )
+                )
               in
 
               5 | Contract
@@ -1304,7 +1369,7 @@
               # Examples
 
               ```nickel
-              let AlwaysFailWithNotes = std.contract.custom (fun label _value =>
+              let AlwaysFailWithNotes = std.contract.delayed (fun label _ =>
                 label
                 |> std.contract.label.append_note "This is note 1"
                 |> std.contract.label.append_note "This is note 2"
@@ -1344,22 +1409,24 @@
           For backward-compatibility reasons, the argument can also be a naked
           function `Label -> Dyn -> Dyn`, but this is discouraged and will be
           deprecated in the future. For such a contract, please wrap it using
-          `std.contract.custom` instead.
+          `std.contract.custom` or `std.contract.delayed` instead.
 
           # Examples
 
           ```nickel
-          let Nullable = fun param_contract =>
-            std.contract.custom (fun label value =>
-              if value == null then
-                null
-              else
-                std.contract.apply param_contract label value
-            )
+          let Nullable = fun Contract =>
+            std.contract.custom
+              (fun value =>
+                if value == null then
+                  'Ok
+                else
+                  'Proceed
+              )
+              (std.contract.apply Contract)
           in
+
           let Contract = Nullable {foo | Number} in
-          ({ foo = 1 } | Contract)
-          => { foo = 1 }
+          { foo = 1 } | Contract
           ```
 
           # Diagnostics stack
@@ -1373,7 +1440,7 @@
           ## Illustration
 
           ```nickel
-          let ChildContract = std.contract.custom (fun label value =>
+          let ChildContract = std.contract.delayed (fun label value =>
             label
             |> std.contract.label.with_message "child's message"
             |> std.contract.label.append_note "child's note"
@@ -1381,7 +1448,7 @@
           )
           in
 
-          let ParentContract = std.contract.custom (fun label value =>
+          let ParentContract = std.contract.delayed (fun label value =>
             let label =
               label
               |> std.contract.label.with_message "parent's message"
@@ -1397,6 +1464,20 @@
           This example will print two diagnostics: the main one, using the
           message and note of the child contract, and a secondary diagnostic,
           using the parent contract message and note.
+
+          While we wrote both contracts as delayed contracts, note that this
+          behavior is also observed if e.g. `ChildContract` is a validator
+          instead:
+
+          ```nickel
+          let ChildContract = std.contract.from_validator (fun _ =>
+            'Error {
+              message = "child's message",
+              notes = ["child's note"]
+            }
+          ) in
+          # etc.
+          ```
         "%
       = fun contract label value =>
         %contract/apply% contract (%label/push_diag% label) value,
@@ -1432,6 +1513,7 @@
             "%
           = fun Domain Codomain =>
             %contract/custom%
+              null
               (
                 fun label function =>
                   fun arg =>
@@ -1452,7 +1534,7 @@
               checked by an associated static type annotation.
             "%
           =
-            %contract/from_validator%
+            %contract/custom%
               (
                 fun value =>
                   if %typeof% value == 'Number && value < 0 then
@@ -1462,7 +1544,8 @@
                     }
                   else
                     'Ok
-              ),
+              )
+              null,
 
         RangeFun
           | doc m%"
@@ -1488,6 +1571,7 @@
             fun Codomain =>
               let RangeSecond = fun start =>
                 %contract/custom%
+                  null
                   (
                     fun label value =>
                       if %typeof% start == 'Number && %typeof% value == 'Number then
@@ -1534,6 +1618,7 @@
 
             let ArrayIndexFirst =
               %contract/custom%
+                null
                 (
                   fun label value =>
                     if %typeof% value == 'Number then
@@ -1550,6 +1635,7 @@
 
             let ArrayIndexSecond = fun type min_size =>
               %contract/custom%
+                null
                 (
                   fun label value =>
                     if %typeof% min_size == 'Number && %typeof% value == 'Array then
@@ -1606,6 +1692,7 @@
 
             let SliceIndexFirst =
               %contract/custom%
+                null
                 (
                   fun label value =>
                     if %typeof% value == 'Number then
@@ -1622,6 +1709,7 @@
 
             let SliceIndexSecond = fun start =>
               %contract/custom%
+                null
                 (
                   fun label value =>
                     if %typeof% start == 'Number && %typeof% value == 'Number then
@@ -1644,6 +1732,7 @@
 
             let ArraySliceArray = fun end_index =>
               %contract/custom%
+                null
                 (
                   fun label value =>
                     if %typeof% end_index == 'Number && %typeof% value == 'Array then
@@ -1707,6 +1796,7 @@
             fun Codomain =>
               let HasFieldSecond = fun field =>
                 %contract/custom%
+                  null
                   (
                     fun label value =>
                       if %typeof% field == 'String
@@ -1760,7 +1850,10 @@
         ("tag" | std.enum.Enum) =>
           error
         "%
-      = %contract/from_predicate% std.is_enum,
+      =
+        %contract/custom%
+          (fun value => if std.is_enum value then 'Ok else 'Error {})
+          null,
 
     TagOrString
       | doc m%%"
@@ -1801,6 +1894,7 @@
       "%%
       =
         %contract/custom%
+          null
           (
             fun label value =>
               let label =
@@ -1865,7 +1959,7 @@
     to_tag_and_arg
       | Enum -> { tag | String, arg | optional }
       | doc m%"
-        Convert an enum to record with a string tag and an optional argument. If
+        Converts an enum to record with a string tag and an optional argument. If
         the enum is an enum tag, the `arg` field is simply omitted.
 
         `std.enum.from_tag_and_arg` provides the inverse transformation,
@@ -1893,7 +1987,7 @@
     from_tag_and_arg
       | { tag | String, arg | optional } -> Enum
       | doc m%"
-        Create an enum from a string tag and an optional argument. If the `arg`
+        Creates an enum from a string tag and an optional argument. If the `arg`
         field is omitted, a bare enum tag is created.
 
         `std.enum.to_tag_and_value` provides the inverse transformation,
@@ -2065,7 +2159,7 @@
         ```
       "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'Number then
@@ -2075,7 +2169,8 @@
                   'Error { message = "not an integer" }
               else
                 'Error { message = "not a number" }
-          ),
+          )
+          null,
 
     Nat
       | doc m%"
@@ -2093,7 +2188,7 @@
         ```
       "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'Number then
@@ -2103,7 +2198,8 @@
                   'Error { message = "not a natural" }
               else
                 'Error { message = "not a number" }
-          ),
+          )
+          null,
 
     PosNat
       | doc m%"
@@ -2121,7 +2217,7 @@
         ```
       "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'Number then
@@ -2131,7 +2227,8 @@
                   'Error { message = "not positive integer" }
               else
                 'Error { message = "not a number" }
-          ),
+          )
+          null,
 
     NonZero
       | doc m%"
@@ -2147,7 +2244,7 @@
         ```
       "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'Number then
@@ -2157,7 +2254,8 @@
                   'Error { message = "is zero" }
               else
                 'Error { message = "not a number" }
-          ),
+          )
+          null,
 
     is_integer
       : Number -> Bool
@@ -2823,7 +2921,7 @@
         let pattern = m%"^[+-]?(\d+(\.\d*)?(e[+-]?\d+)?|\.\d+(e[+-]?\d+)?)$"% in
         let is_num_literal = %string/is_match% pattern in
 
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'String then
@@ -2833,7 +2931,8 @@
                   'Error { message = "invalid number literal" }
               else
                 'Error { message = "not a string" }
-          ),
+          )
+          null,
 
     Character
       | doc m%"
@@ -2853,7 +2952,7 @@
         ```
       "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'String then
@@ -2863,7 +2962,8 @@
                   'Error { message = "length different than one" }
               else
                 'Error { message = "not a string" }
-          ),
+          )
+          null,
 
     Stringable
       | doc m%"
@@ -2892,18 +2992,26 @@
         ```
       "%
       =
-        %contract/from_predicate%
+        %contract/custom%
           (
             fun value =>
               let type = std.typeof value in
-              value == null
-              || type == 'Number
-              || type == 'Bool
-              || type == 'String
-              # note that `type == 'Enum` isn't sufficient, as it includes enum
-              # variants
-              || std.enum.is_enum_tag value
-          ),
+              let check =
+                value == null
+                || type == 'Number
+                || type == 'Bool
+                || type == 'String
+                # note that `type == 'Enum` isn't sufficient, as it includes enum
+                # variants
+                || std.enum.is_enum_tag value
+              in
+
+              if check then
+                'Ok
+              else
+                'Error {}
+          )
+          null,
 
     NonEmpty
       | doc m%"
@@ -2921,7 +3029,7 @@
         ```
       "%
       =
-        %contract/from_validator%
+        %contract/custom%
           (
             fun value =>
               if %typeof% value == 'String then
@@ -2931,7 +3039,8 @@
                   'Error { message = "empty string" }
               else
                 'Error { message = "not a string" }
-          ),
+          )
+          null,
 
     join
       : String -> Array String -> String
@@ -3424,7 +3533,7 @@
          => true
         ```
         "%
-      = %contract/from_predicate% (fun x => x),
+      = %contract/custom% (fun value => if value then 'Ok else 'Error {}) null,
 
     assert_all
       | Array Assert -> Bool
@@ -3731,7 +3840,7 @@
       ```
     "%
     = fun msg =>
-      %contract/from_validator% (fun _ => 'Error { message = msg }),
+      %contract/custom% (fun _ => 'Error { message = msg }) null,
 
   fail_with
     | String -> Dyn
diff --git a/doc/manual/contracts.md b/doc/manual/contracts.md
index d8c4f56b..0f7a583c 100644
--- a/doc/manual/contracts.md
+++ b/doc/manual/contracts.md
@@ -198,99 +198,24 @@ error: contract broken by a value
 "foo"
 ```
 
-### Generic custom contracts
+### General custom contracts
 
 In some situations, even validators aren't sufficient. For example, when writing
-[lazy contracts](#laziness) or [parametrized
+[delayed contracts](#delayed-contracts) or [parametrized
 contracts](#parametrized-contracts), you might not be able to decide right away
-if a value satisfies a contract. Please refer to the aforementioned sections for
-more details. In this case, the most general form for creating a custom contract
-is `std.contract.custom`. Here is the `IsFoo` contract written with `custom`:
+if a value satisfies a contract.
 
-```nickel
-{
-  IsFoo =
-    std.contract.custom
-      (
-        fun label value =>
-          value |> match {
-            "foo" => value,
-            value if std.is_string value =>
-              std.contract.blame_with_message "not equal to \"foo\"" label,
-            _ =>
-              std.contract.blame_with_message "not a string" label,
-          }
-      )
-}
-```
-
-A general custom contract is a function of two arguments:
-
-- A `label`.
-- The value being checked.
-
-Upon success, the contract must return the original value. We will see
-the reason why in the [laziness section](#laziness). To signal failure,
-a custom contract uses `std.contract.blame` which takes the label as an argument.
-
-The label is a special object that is automatically passed to the custom
-contract by the interpreter and which contains tracking information for error
-reporting. Custom contracts can use the label to customize error reporting upon
-failure using the functions from `std.contract.label`, which set various
-attributes of the label. As we will see in the [contracts parametrized by
-contracts section](#contracts-parametrized-by-contracts), the label must also be
-provided when applying a subcontract.
-
-In the example above, we used `std.contract.blame_with_message message label`,
-which is just shorthand for setting the message of the label and then calling
-`std.contract.blame`:
-
-```nickel #parse
-label
-|> std.contract.label.with_message message
-|> std.contract.blame
-```
-
-`blame` immediately aborts the execution and reports a contract violation error.
-
-In `IsFoo`, we first test if the value is a string, and then if it is equal to
-`"foo"`, in which case the contract succeeds. Otherwise, the contract fails with
-appropriate error messages. Let us try:
-
-```nickel #repl
-# hide-range{1-10}
-
-> let IsFoo =
-  std.contract.custom
-    (
-      fun label value =>
-        if std.is_string value then
-          if value == "foo" then
-            value
-          else
-            std.contract.blame_with_message "not equal to \"foo\"" label
-        else
-          std.contract.blame_with_message "not a string" label
-    )
+For those use-cases, the stdlib provides two additional contract constructors,
+`std.contract.custom` and `std.contract.delayed`. The former is the most general
+form, which takes as arguments two functions: the *immediate* part and the
+*delayed* part. Other contract constructors such as
+`std.contract.from_predicate`, `std.contract.from_validator` or
+`std.contract.delayed` are just special cases of `std.contract.custom`.
 
-> 1 | IsFoo
-error: contract broken by a value
-       not a string
-[...]
-
-> "a" | IsFoo
-error: contract broken by a value
-       not equal to "foo"
-[...]
-
-> "foo" | IsFoo
-"foo"
-```
-
-Note that there is absolutely no good reason to write the particular `IsFoo`
-contract as a general custom contract: a validator is better suited for this
-simple case. We did it for illustrative purpose. Later sections will show more
-relevant examples where `custom` is required.
+At this point, we haven't covered enough material to present a compelling
+example of a general custom contracts. To see `std.contract.custom` in action,
+please refer to the aforementioned sections, and in particular the one on
+[delayed contracts](#delayed-contracts).
 
 ### Parametrized contracts
 
@@ -382,23 +307,27 @@ function `Contract label value`, but use `std.contract.apply Contract label
 value` instead.
 
 Parameters that are also contracts are usually generic. In particular, they are
-not necessarily predicates or validators. In consequence, a contract
-parametrized by another unknown contract must usually be written using the most
-general constructor `std.contract.custom`.
+not necessarily immediate contracts. In consequence, a contract parametrized by
+another unknown contract must usually be written as [a delayed
+contract](#delayed-contracts), using the most general constructor
+`std.contract.custom`.
 
 One example is the `Nullable` contract, which accepts a value that is either
-`null` or of some other given format:
+`null` or of some other given format. If the argument is `null`, we accept the
+value immediately. Otherwise, we proceed with the delayed part, which is just
+the contract application of the given argument. See [delayed
+contracts](#delayed-contracts) for more details on `std.contract.custom`.
 
 ```nickel
 let Nullable = fun Contract =>
   std.contract.custom
-    (
-      fun label value =>
-        if value == null then
-          value
-        else
-          std.contract.apply Contract label value
+    (fun value =>
+      if value == null then
+        'Ok
+      else
+        'Proceed
     )
+    (std.contract.apply Contract)
 in
 
 [
@@ -570,14 +499,14 @@ example:
 
 > {data = "", must_be_very_secure = false} | Secure
 error: non mergeable terms
-  ┌─ <repl-input-19>:1:36
+  ┌─ <repl-input-15>:1:36
   │
 1 │  {data = "", must_be_very_secure = false} | Secure
   │                                    ^^^^^    ------ originally merged here
   │                                    │
   │                                    cannot merge this expression
   │
-  ┌─ <repl-input-17>:2:34
+  ┌─ <repl-input-13>:2:34
   │
 2 │     must_be_very_secure | Bool = true,
   │                                  ^^^^ with this expression
@@ -644,7 +573,7 @@ contract to each element:
 
 > [1000, 10001, 2] | Array VeryBig
 error: contract broken by a value
-  ┌─ <repl-input-25>:1:16
+  ┌─ <repl-input-21>:1:16
   │
 1 │  [1000, 10001, 2] | Array VeryBig
   │                ^          ------- expected array element type
@@ -712,7 +641,7 @@ functions as parameters. Here is an example:
 > let apply_fun | (Number -> Number) -> Number = fun f => f 0 in
   apply_fun (fun x => "a")
 error: contract broken by the caller
-  ┌─ <repl-input-28>:1:29
+  ┌─ <repl-input-24>:1:29
   │
 1 │  let apply_fun | (Number -> Number) -> Number = fun f => f 0 in
   │                             ------ expected return type of a function provided by the caller
@@ -740,24 +669,30 @@ strings and values satisfy `Contract`, for example:
 5
 ```
 
-## Laziness
+## Delayed contracts
+
+This section covers delayed contracts, why they are useful, and how to write
+them. You might come across the term *lazy contracts* elsewhere in the
+documentation, which was the previous denomination of delayed contracts: the two
+notions are strictly equivalent.
+
+### Lazy evaluation
 
 In the [section on writing a custom contract with
-`std.contract.custom`](#generic-custom-contracts), we noted the strange fact
+`std.contract.custom`](#general-custom-contracts), we noted the strange fact
 that a general custom contract must return a value, instead of just returning
-e.g. a boolean to indicate success or failure. A custom contract could even
-theoretically always return `null`, as failure is handled separately by
-aborting, which is a bit unsettling (although there is no reasonable
-justification for doing that!).
+e.g. a boolean to indicate success or failure.
 
-What's more, the contracts we have written so far always returned the original
-value unmodified upon success, which doesn't sound very useful: after all, the
-caller and the interpreter already had access to this value to begin with.
+The contracts we have written so far always returned the original value
+unmodified upon success, which doesn't sound very useful: after all, the caller
+and the interpreter already had access to this value to begin with.
 
 The motivation for this return value is laziness. Nickel is designed to be
 *lazy*, only evaluating values on-demand.
 
-For example, record fields are not evaluated until they are accessed, printed
+When evaluating a record, Nickel only evaluates the *spine* of the record, that
+is the structure of the record (the field names and their metadata), but not its
+content. The field values are left unevaluated, until they are accessed, printed
 (in the REPL or as a result of `nickel`), or serialized/exported. Thanks to
 laziness, you can for example query specific information on a large
 configuration without having to actually evaluate everything. We will use the
@@ -782,134 +717,202 @@ error: contract broken by the value of `fail`
 See how the command `:query config.data` succeeds, although the field `fail`
 causes an error when evaluated.
 
-A large configuration will most probably have a root contract attached,
-corresponding to its schema. If this contract checked everything eagerly,
-forcing the evaluation of most of the configuration, we would lose the benefits
-of laziness. Thus, *we want contracts to be lazy as well*. In particular, a
-subcontract attached to a field should only fire when the field is requested.
-This is the case with record contracts, and in general with all native contracts
-combinators.
+A large configuration usually has a root contract attached, corresponding to its
+schema. If this contract would perform all the checks immediately, forcing the
+evaluation of most of the configuration, we would lose the benefits of laziness.
+Thus, *we want contracts to be lazy as well*. In particular, when writing a
+contract `{foo | FooContract}`, we want that `FooContract` fires only when the
+field `foo` is requested. This is indeed the case: built-in contract
+combinators, that is array contracts, dictionary contracts and function
+contracts, all have a *delayed part*.
+
+### Immediate and delayed
+
+The most general form of contract thus has two parts:
+
+- the *immediate part* which is evaluated first and produces an answer right
+    away. This answer is either:
+  - `'Ok` to signal immediate success and forego the delayed checks (if any)
+  - `'Proceed` to signal that the immediate part didin't detect any error, but
+      the delayed checks must still be performed
+  - `'Error {..}` to signal immediate failure
+
+  In practice, the immediate part is the first argument to
+  `std.contract.custom`, and is indeed a function from an unknwon value to one
+  of the three possible answers, of type:
+
+  ```nickel
+  Dyn -> [|
+    'Ok,
+    'Proceed,
+    'Error {
+      message | String | optional,
+      notes | Array String | optional
+    }
+  |]
+  ```
+
+- the *delayed part* which returns the value with delayed checks integrated
+    inside. Those checks will fire only when further data is requested. This is
+    the second argument of `std.contract.custom`. The delayed part is a function
+    taking a label, the value, and returning the augmented value; it has type:
+
+  ```nickel
+  Dyn -> Dyn -> Dyn
+  ```
+
+Take the record contract `{foo | FooContract}` applied to the value `{foo =
+42}`:
+
+- the immediate part of this contract will check that the value is a record and
+    that its only field is `foo`, which is the case.
+- the delayed part will push the `FooContract` contract inside the value and
+    return the result, giving `{foo | FooContract = 42}`.
+
+A contract built from a predicate or a validator (using respectively
+`std.contract.from_predicate` and `std.contract.from_validator`) only has an
+immediate part. Dually, a contract built from `std.contract.delayed` only has a
+delayed part.
+
+### Writing a delayed contract
+
+First, let us mention that writing a contract with a delayed part is not that
+common. Predicates and validators should cover most of your needs. Delayed
+contracts are most often native Nickel contract combinators. It's still possible
+to need one's own specialized variant of built-in contracts, which will be our
+working example in this section.
+
+Another use-case for custom delayed contracts is to write a contract that is
+parametrized by another contract, such as the `Nullable` contract implemented in
+[the parametrized contracts section](#contracts-parametrized-by-contracts).
+Because the parameter contract can be delayed, `Nullable` needs to be written as
+a delayed contract as well.
 
-### Writing lazy contracts
+The delayed part of a general custom contract is a function of two arguments:
+
+- A `label`.
+- The value being checked.
+
+Upon success, the contract must return the original value with potential delayed
+check inside. To signal failure, a custom contract uses `std.contract.blame`
+which takes the label as an argument. `blame` immediately aborts the execution
+and reports a contract violation error.
+
+The label is a special object which is automatically passed to the custom
+contract by the interpreter. The label contains tracking information for error
+reporting. Custom contracts can use the label to customize error reporting upon
+failure using the functions from `std.contract.label`, which set various
+attributes of the label and return the new modified label. As seen in the
+[contracts parametrized by contracts
+section](#contracts-parametrized-by-contracts), the label must also be provided
+when applying a subcontract.
 
 Imagine we want to write a contract similar to `{_ | Bool}`, that is a
-dictionary of booleans, but we also want keys to be valid number (although
+dictionary of booleans, but we also want keys to be valid numbers (although
 represented as strings). A valid value could look like `{"1": true, "2": false,
-"10": true}`. If we use a predicate, it would be impossible to make it lazy: as
-soon as your contract is called, you would need to produce a `true` or `false`
-answer, and checking that fields are all `Bool` requires evaluating them first.
+"10": true}`. If we use an immediate contract (a predicate or a validator), it's
+impossible to preserve laziness: as soon as your contract is called, you would
+need to produce a yes or no answer, and checking that fields are all `Bool`
+requires evaluating their content first.
 
 What we can do is to not perform all the checks right away, but **return a new
-value which is wrapping the original value with delayed checks inside**. This
-is the rationale behind contracts returning a value. Let us see:
+value which is wrapping the original value with delayed checks inside**:
 
 ```nickel
 {
   NumberBoolDict =
     std.contract.custom
-      (
-        fun label value =>
-          if std.is_record value then
-            let check_fields =
-              value
-              |> std.record.fields
-              |> std.array.fold_left
-                (
-                  fun acc field_name =>
-                    if std.string.is_match "^\\d+$" field_name then
-                      acc # unused and always null through iteration
-                    else
-                      std.contract.blame_with_message "field name `%{field_name}` is not a number" label
-                )
-                null
-            in
-            value
-            |> std.record.map
-              (
-                fun name value =>
-                  let label_with_msg =
-                    std.contract.label.with_message "field `%{name}` is not a boolean" label
-                  in
-                  std.contract.apply Bool label_with_msg value
-              )
-            |> std.seq check_fields
-          else
-            std.contract.blame_with_message "not a record" label
-    )
+      (fun value =>
+        if std.is_record value then
+          value
+          |> std.record.fields
+          |> std.array.fold_right
+            (fun field_name rest =>
+              if std.string.is_match "^\\d+$" field_name then
+                rest
+              else
+                'Error {
+                  message = "field name `%{field_name}` is not a number"
+                }
+            )
+            'Proceed
+        else
+          'Error { message = "not a record" }
+      )
+      (fun label value =>
+        value
+        |> std.record.map
+          (
+            fun name value =>
+              let label_with_msg =
+                std.contract.label.with_message "field `%{name}` is not a boolean" label
+              in
+
+              std.contract.apply Bool label_with_msg value
+          )
+      )
 }
 ```
 
 There is a lot to unwrap here. Please refer to the [syntax section](./syntax.md)
-if you have trouble parsing the example. We first check that the value is a
-record on line 3. We then define `check_fields` on line 4, an expression that
-goes over all the record field names and check that each one is a sequence of
-digits. We use a left fold with a dummy `null` accumulator as a way to just
-iterate through the array without building up anything interesting.
-
-For laziness, the interesting bit happens here:
-
-```nickel #parse
-value
-|> std.record.map
-  (
-    fun name value =>
-      let label_with_msg =
-        std.contract.label.with_message "field `%{name}` is not a boolean" label
-      in
-      std.contract.apply Bool label_with_msg value
-  )
-|> std.seq check_fields
-```
-
-This is the final return value of the contract (at least when `value` is a
-record). This code takes the original record, and maps a function over it which
-substitutes each field for the same value but wrapped in a `Bool` contract.
-Because records (and record mapping) are lazy, *this doesn't actually execute
-the `Bool` contracts right away*. Each contract will only be run when the
-corresponding field will be requested.
-
-Finally, we sequence the result with `check_fields`: if we remove this last line
-and return the mapped value directly, because Nickel is lazy and `check_fields`
-is not used elsewhere, the check wouldn't actually trigger. Using `seq` first
-forces the evaluation of `check_fields` unconditionally, ignores the resulting
-value and continues with the second argument (here, our wrapped `value`).
+if you have trouble parsing the example.
+
+The first function defines the immediate part of the contract. We first check if
+the value is a record and return `'Error {..}` otherwise. Then, we loop over
+over all the record field names and check that each one is a sequence of digits.
+We use a right fold because of its short-circuiting capabilities: as soon as an
+`'Error` is encountered, `fold_right` doesn't need to evaluate the rest and
+returns immediately. We provide the base value `'Proceed`, which will be
+picked if all the fields are valid, and indicates that the immediate part has
+succeeded and that the contract system should now proceed with the delayed part.
+
+We could theoretically have made the whole contact delayed by pushing those
+checks down each field, together with the `Bool` contract application, but it's
+neither necessary nor desirable: as long as we don't force fields, it's better
+to perform as many checks as possible in the immediate part, to report errors
+early and because it behaves better with respect to some contract operations.
+
+The delayed part maps a function over the record which substitutes each field
+for the same value but wrapped in a `Bool` contract. Because records (and record
+mapping) are lazy, *this doesn't actually execute the `Bool` contracts right
+away*. Each contract will only be run when the corresponding field will be
+requested.
 
 Let us see if we indeed preserved laziness:
 
 ```nickel #repl
-#hide-range{1-33}
+#hide-range{1-31}
 
 > let NumberBoolDict =
     std.contract.custom
-      (
-        fun label value =>
-          if std.is_record value then
-            let check_fields =
-              value
-              |> std.record.fields
-              |> std.array.fold_left
-                (
-                  fun acc field_name =>
-                    if std.string.is_match "^\\d+$" field_name then
-                      acc # unused and always null through iteration
-                    else
-                      std.contract.blame_with_message "field name `%{field_name}` is not a number" label
-                )
-                null
-            in
-            value
-            |> std.record.map
-              (
-                fun name value =>
-                  let label_with_msg =
-                    std.contract.label.with_message "field `%{name}` is not a boolean" label
-                  in
-                  std.contract.apply Bool label_with_msg value
-              )
-            |> std.seq check_fields
-          else
-            std.contract.blame_with_message "not a record" label
-    )
+      (fun value =>
+        if std.is_record value then
+          value
+          |> std.record.fields
+          |> std.array.fold_right
+            (fun field_name rest =>
+              if std.string.is_match "^\\d+$" field_name then
+                rest
+              else
+                'Error {
+                  message = "field name `%{field_name}` is not a number"
+                }
+            )
+            'Proceed
+        else
+          'Error { message = "not a record" }
+      )
+      (fun label value =>
+        value
+        |> std.record.map
+          (
+            fun name value =>
+              let label_with_msg =
+                std.contract.label.with_message "field `%{name}` is not a boolean" label
+              in
+              std.contract.apply Bool label_with_msg value
+          )
+      )
 
 > let config | NumberBoolDict = {
     "1" | std.FailWith "ooch" = null, # same as our previous "fail"
@@ -924,39 +927,38 @@ Yes! Our contract doesn't unduly cause the evaluation of the field `"1"`. Does
 it check anything, though?
 
 ```nickel #repl
-#hide-range{1-33}
+#hide-range{1-31}
 
 > let NumberBoolDict =
     std.contract.custom
-      (
-        fun label value =>
-          if std.is_record value then
-            let check_fields =
-              value
-              |> std.record.fields
-              |> std.array.fold_left
-                (
-                  fun acc field_name =>
-                    if std.string.is_match "^\\d+$" field_name then
-                      acc # unused and always null through iteration
-                    else
-                      std.contract.blame_with_message "field name `%{field_name}` is not a number" label
-                )
-                null
-            in
-            value
-            |> std.record.map
-              (
-                fun name value =>
-                  let label_with_msg =
-                    std.contract.label.with_message "field `%{name}` is not a boolean" label
-                  in
-                  std.contract.apply Bool label_with_msg value
-              )
-            |> std.seq check_fields
-          else
-            std.contract.blame_with_message "not a record" label
-    )
+      (fun value =>
+        if std.is_record value then
+          value
+          |> std.record.fields
+          |> std.array.fold_right
+            (fun field_name rest =>
+              if std.string.is_match "^\\d+$" field_name then
+                rest
+              else
+                'Error {
+                  message = "field name `%{field_name}` is not a number"
+                }
+            )
+            'Proceed
+        else
+          'Error { message = "not a record" }
+      )
+      (fun label value =>
+        value
+        |> std.record.map
+          (
+            fun name value =>
+              let label_with_msg =
+                std.contract.label.with_message "field `%{name}` is not a boolean" label
+              in
+              std.contract.apply Bool label_with_msg value
+          )
+      )
 
 > let config | NumberBoolDict = {
     not_a_number = false,
@@ -980,26 +982,11 @@ error: contract broken by a value
 
 It does!
 
-#### Remark: lazy, always?
-
-Note that our check that field names are digits is not lazy in the sense that
-even when requesting another field `"0"`, we've already triggered this check for
-all field names. When evaluating a record lazily, Nickel must still
-determine its structure and in particular the field names. What's not evaluated
-right away is the *content* of each field.
-
-Thus, performing the check for field names right away is fine: early error
-reporting is a good thing, and the field names are readily available, so we
-don't force the evaluation of something that wouldn't be evaluated without the
-contract. We could have made this check lazy by putting it inside each field,
-together with the `Bool` contract application, but in this case it's not
-necessary.
-
 #### Conclusion
 
 Our `NumberBoolDict` contract doesn't perform all the checks needed right away.
-Instead, **it returns a new value, which is wrapping the original value with
-delayed checks inside**. Doing so preserves laziness of the language and only
-triggers the checks when the values are used or exported in a configuration.
-This is the reason for custom contracts to return a value, which must be the
-original value with potential delayed checks inside.
+Instead, it performs some of them immediately and **returns a new value, which
+is wrapping the original value with delayed checks inside**. Doing so preserves
+laziness of the language and only triggers the checks when the values are used
+or exported in a configuration. This is the reason for general custom contracts
+to have an immediate part and a delayed part.
diff --git a/doc/manual/merging.md b/doc/manual/merging.md
index 372c6d30..d6ec7d3f 100644
--- a/doc/manual/merging.md
+++ b/doc/manual/merging.md
@@ -166,7 +166,7 @@ unless one of the following condition hold:
   arguments are merged recursively: that is,
   `'Tag arg1 & 'Tag arg2` is `'Tag (arg1 & arg2)`.
 - They are both arrays, and they are equal (checked by generating an application
-  of the lazy contract `std.contract.Equal`)
+  of the delayed contract `std.contract.Equal`)
 - They are both equal to `null`
 
 ### Specification
diff --git a/doc/manual/typing.md b/doc/manual/typing.md
index 7163ab19..9e0fd84d 100644
--- a/doc/manual/typing.md
+++ b/doc/manual/typing.md
@@ -588,9 +588,9 @@ calling to the statically typed `std.array.filter` from dynamically typed code:
 ```nickel #repl
 > std.array.filter (fun x => if x % 2 == 0 then x else null) [1,2,3,4,5,6]
 error: contract broken by the caller of `filter`
-    ┌─ <stdlib/std.ncl>:377:25
+    ┌─ <stdlib/std.ncl>:378:25
     │
-377 │       : forall a. (a -> Bool) -> Array a -> Array a
+378 │       : forall a. (a -> Bool) -> Array a -> Array a
     │                         ---- expected return type of a function provided by the caller
     │
     ┌─ <repl-input-6>:1:55
diff --git a/examples/config-gcc/README.md b/examples/config-gcc/README.md
index 3296ac19..5ddd6745 100644
--- a/examples/config-gcc/README.md
+++ b/examples/config-gcc/README.md
@@ -28,4 +28,9 @@ This example defines a couple contracts:
 ## Playground
 
 You can try to break any of the previous contracts to see what happens: provide
-a non supported flag, a `path_libc` that doesn't end in `".so"`, and so on.
+a non supported flag, a `path_libc` that doesn't end in `".so"`, and so on. You
+can do it from the command line, for example:
+
+```console
+nickel export config-gcc.ncl -- --override "path_libc=/usr/lib/libc.exe"
+```
diff --git a/examples/config-gcc/config-gcc.ncl b/examples/config-gcc/config-gcc.ncl
index 145daaa9..20e79938 100644
--- a/examples/config-gcc/config-gcc.ncl
+++ b/examples/config-gcc/config-gcc.ncl
@@ -12,24 +12,25 @@ let GccFlag =
 
   std.contract.custom
     (
-      fun label =>
-        match {
-          value if std.is_string value && is_valid_flag value =>
-            value,
-          { flag, arg } if std.array.elem flag supported_flags =>
-            # Normalize the tag to a string
-            "%{flag}%{arg}",
-          value if std.is_string value =>
-            std.contract.blame_with_message "unknown flag %{value}" label,
-          { flag, arg = _ } =>
-            std.contract.blame_with_message "unknown flag %{flag}" label,
-          { .. } =>
-            std.contract.blame_with_message
-              "bad record structure: missing field `flag` or `arg`"
-              label,
-          _ => std.contract.blame_with_message "expected record or string" label,
-        }
+      match {
+        value if std.is_string value && is_valid_flag value => 'Ok,
+        { flag, arg } if std.array.elem flag supported_flags => 'Proceed,
+        value if std.is_string value =>
+          'Error { message = "unknown flag %{value}" },
+        { flag, arg = _ } =>
+          'Error { message = "unknown flag %{flag}" },
+        { .. } =>
+          'Error {
+            message = "bad record structure: missing field `flag` or `arg`",
+          },
+        _ => 'Error { message = "expected record or string" },
+      }
     )
+    # The delayed part is only used for normalization. Given the immediate part,
+    # we enter the delayed part if and only if the flag has the form
+    # `{flag, arg}`. We don't perform any check and just normalize it to a
+    # string
+    (fun _ value => "%{value.flag}%{value.arg}")
 in
 
 let Path =
diff --git a/flake.lock b/flake.lock
index 7fc1265e..7b337170 100644
--- a/flake.lock
+++ b/flake.lock
@@ -515,11 +515,11 @@
         "rust-overlay": "rust-overlay_2"
       },
       "locked": {
-        "lastModified": 1717691046,
-        "narHash": "sha256-bVDoatFPN7NRuAf4URTFNrYVU7phz2vJpROmnVmqvfw=",
+        "lastModified": 1718001536,
+        "narHash": "sha256-gEgYnX8KyJV0c5wAIN0QAf9/vFuCzEVoaqb12UfQgSQ=",
         "owner": "tweag",
         "repo": "topiary",
-        "rev": "1f11babe0d037cd84f8d909129dce497323f1e49",
+        "rev": "c92b16c72707549fbc1dcded4accba427be658e6",
         "type": "github"
       },
       "original": {


Test patch:
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
index 401ef8b1..7591a62b 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `at`
        invalid array indexing
-    ┌─ <stdlib/std.ncl>:166:9
+    ┌─ <stdlib/std.ncl>:167:9
     │
-166 │       | std.contract.unstable.IndexedArrayFun 'Index
+167 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_empty_array.ncl:3:16
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
index 7e00925c..89879ec0 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `at`
        invalid array indexing
-    ┌─ <stdlib/std.ncl>:166:9
+    ┌─ <stdlib/std.ncl>:167:9
     │
-166 │       | std.contract.unstable.IndexedArrayFun 'Index
+167 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_out_of_bound.ncl:3:16
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
index 83a3dffa..64c97a07 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `range`
        invalid range
-    ┌─ <stdlib/std.ncl>:677:9
+    ┌─ <stdlib/std.ncl>:678:9
     │
-677 │       | std.contract.unstable.RangeFun Dyn
+678 │       | std.contract.unstable.RangeFun Dyn
     │         ---------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_range_reversed_indices.ncl:3:19
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
index f4c4f6b9..b0ff5a84 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `range_step`
        invalid range step
-    ┌─ <stdlib/std.ncl>:652:9
+    ┌─ <stdlib/std.ncl>:653:9
     │
-652 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
+653 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
     │         ----------------------------------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_range_step_negative_step.ncl:3:27
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
index 17416597..ee32a042 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
@@ -3,9 +3,9 @@ source: cli/tests/snapshot/main.rs
 expression: err
 ---
 error: contract broken by the caller of `map`
-    ┌─ <stdlib/std.ncl>:150:33
+    ┌─ <stdlib/std.ncl>:151:33
     │
-150 │       : forall a b. (a -> b) -> Array a -> Array b
+151 │       : forall a b. (a -> b) -> Array a -> Array b
     │                                 ------- expected type of the argument provided by the caller
     │
     ┌─ [INPUTS_PATH]/errors/caller_contract_violation.ncl:3:31
diff --git a/core/tests/integration/inputs/contracts/custom_generic_fail.ncl b/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
index 3af2c26e..1ea2bd72 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
@@ -2,5 +2,5 @@
 #
 # [test.metadata]
 # error = 'EvalError::BlameError'
-let AlwaysFail = std.contract.custom (fun label _ => std.contract.blame label) in
+let AlwaysFail = std.contract.custom (fun _ => 'Proceed) (fun label _ => std.contract.blame label) in
 3 | AlwaysFail
diff --git a/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl b/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
new file mode 100644
index 00000000..e4a935db
--- /dev/null
+++ b/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
@@ -0,0 +1,6 @@
+# test.type = 'error'
+#
+# [test.metadata]
+# error = 'EvalError::BlameError'
+let AlwaysFail = std.contract.custom (fun _ => 'Error {}) (fun _ value => value) in
+3 | AlwaysFail
diff --git a/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl b/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
index f542410d..f75f7703 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
@@ -1,3 +1,9 @@
 # test.type = 'pass'
-let AlwaysSucceed = std.contract.custom (fun _ value => value) in
-(3 | AlwaysSucceed) == 3
+let AlwaysSucceedImmediate = std.contract.custom (fun _ => 'Ok) (fun label _ => std.contract.blame label) in
+let AlwaysSucceedFull = std.contract.custom (fun _ => 'Proceed) (fun _ value => value) in
+
+(
+  3
+    | AlwaysSucceedImmediate
+    | AlwaysSucceedFull
+) == 3