Repository: tweag/nickel
Pull request #1991 (2024-07-08 14:55:48)

adding array constructor to subsumption rule


Patch:
diff --git a/HACKING.md b/HACKING.md
index 66c8f053..605fc2d7 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -148,12 +148,12 @@ Rust integration tests, which run the file and assert that no errors were
 raised during evaluation.
 
 Each of these `.ncl` files is structured as an array of `Bool` expressions,
-which is ultimately passed to `std.contract.check` function defined in the
-standard library. This function applies an `Assert` (`std.test.Assert`)
-contract to each value in the array, which checks that the value it is applied
-to evaluates to `true`. The benefit of using a contract for this is that if a
-test fails we can simply run the file directly using Nickel, which gives better
-error messages than the ones we get by default from `cargo test`.
+which is ultimately passed to `std.test.check` function defined in the standard
+library. This function applies an `Assert` (`std.test.Assert`) contract to each
+value in the array, which checks that the value it is applied to evaluates to
+`true`. The benefit of using a contract for this is that if a test fails we can
+simply run the file directly using Nickel, which gives better error messages
+than the ones we get by default from `cargo test`.
 
 Tests which are expected to fail may be written in Rust in `core/tests/integration`.
 However, simple failure test cases can make use of the test annotation support
diff --git a/core/src/eval/merge.rs b/core/src/eval/merge.rs
index 052bf194..2ffd4abd 100644
--- a/core/src/eval/merge.rs
+++ b/core/src/eval/merge.rs
@@ -26,18 +26,25 @@
 //! depend on each metadata.
 
 use super::*;
-use crate::closurize::Closurize;
-use crate::combine::Combine;
-use crate::error::{EvalError, IllegalPolymorphicTailAction};
-use crate::label::{Label, MergeLabel};
-use crate::position::TermPos;
-use crate::term::{
-    record::{self, Field, FieldDeps, FieldMetadata, RecordAttrs, RecordData},
-    BinaryOp, EnumVariantAttrs, IndexMap, RichTerm, Term, TypeAnnotation,
+use crate::{
+    closurize::Closurize,
+    combine::Combine,
+    error::{EvalError, IllegalPolymorphicTailAction},
+    label::{Label, MergeLabel},
+    position::TermPos,
+    term::{
+        make as mk_term,
+        record::{self, Field, FieldDeps, FieldMetadata, RecordAttrs, RecordData},
+        BinaryOp, EnumVariantAttrs, IndexMap, RichTerm, Term, TypeAnnotation,
+    },
 };
 
 /// Merging mode. Merging is used both to combine standard data and to apply contracts defined as
 /// records.
+///
+/// In [MergeMode::Contract] mode, the merge operator acts like a custom contract. Instead of
+/// returning the result directly, it either returns `'Ok result`, or `'Error {..}` if there were
+/// some unexpected extra fields.
 #[derive(Clone, PartialEq, Debug)]
 pub enum MergeMode {
     /// Standard merging, for combining data.
@@ -84,7 +91,12 @@ pub fn merge<C: Cache>(
         pos: pos2,
     } = t2;
 
-    match (t1.into_owned(), t2.into_owned()) {
+    // Determines if we need to wrap the result in `'Ok` upon successful merging, which is the case
+    // when in contract merge mode. We're going to move out of `mode` at some point, so we need to
+    // save this information now.
+    let wrap_in_ok = matches!(mode, MergeMode::Contract(_));
+
+    let result = match (t1.into_owned(), t2.into_owned()) {
         // Merge is idempotent on basic terms
         (Term::Null, Term::Null) => Ok(Closure::atomic_closure(RichTerm::new(
             Term::Null,
@@ -267,29 +279,28 @@ pub fn merge<C: Cache>(
             } = split::split(r1.fields, r2.fields);
 
             match mode {
-                MergeMode::Contract(label) if !r2.attrs.open && !left.is_empty() => {
+                MergeMode::Contract(_) if !r2.attrs.open && !left.is_empty() => {
                     let fields: Vec<String> =
                         left.keys().map(|field| format!("`{field}`")).collect();
                     let plural = if fields.len() == 1 { "" } else { "s" };
                     let fields_list = fields.join(", ");
 
-                    let label = label
-                        .with_diagnostic_message(format!("extra field{plural} {fields_list}"))
-                        .with_diagnostic_notes(vec![
-                            String::from("Have you misspelled a field?"),
-                            String::from(
-                                "The record contract might also be too strict. By default, \
-                                record contracts exclude any field which is not listed.\n\
-                                Append `, ..` at the end of the record contract, as in \
-                                `{some_field | SomeContract, ..}`, to make it accept extra fields.",
-                            ),
-                        ]);
-
-                    return Err(EvalError::BlameError {
-                        evaluated_arg: label.get_evaluated_arg(cache),
-                        label,
-                        call_stack: CallStack::new(),
-                    });
+                    // The presence of extra fields is an immediate contract error. Thus, instead
+                    // of raising a blame error as for a delayed contract error, which can't be
+                    // caught in user-code, we return an `'Error {..}` value instead.
+                    return Ok(Closure::atomic_closure(
+                        mk_term::enum_variant("Error", Term::Record(RecordData::with_field_values([
+                            ("message".into(), mk_term::string(format!("extra field{plural} {fields_list}"))),
+                            ("notes".into(), Term::Array([
+                                mk_term::string("Have you misspelled a field?"),
+                                mk_term::string(
+                                    "The record contract might also be too strict. By default, \
+                                    record contracts exclude any field which is not listed.\n\
+                                    Append `, ..` at the end of the record contract, as in \
+                                    `{some_field | SomeContract, ..}`, to make it accept extra fields."
+                                ),
+                            ].into_iter().collect(), Default::default()).into())
+                        ])))));
                 }
                 _ => (),
             };
@@ -354,20 +365,24 @@ pub fn merge<C: Cache>(
                 env: Environment::new(),
             })
         }
-        (t1_, t2_) => match (mode, &t2_) {
-            // We want to merge a non-record term with a record contract
-            (MergeMode::Contract(label), Term::Record(..)) => Err(EvalError::BlameError {
-                evaluated_arg: label.get_evaluated_arg(cache),
-                label,
-                call_stack: call_stack.clone(),
-            }),
-            // The following cases are either errors or not yet implemented
-            (mode, _) => Err(EvalError::MergeIncompatibleArgs {
-                left_arg: RichTerm::new(t1_, pos1),
-                right_arg: RichTerm::new(t2_, pos2),
-                merge_label: mode.into(),
-            }),
-        },
+        (t1_, t2_) => Err(EvalError::MergeIncompatibleArgs {
+            left_arg: RichTerm::new(t1_, pos1),
+            right_arg: RichTerm::new(t2_, pos2),
+            merge_label: mode.into(),
+        }),
+    };
+
+    if wrap_in_ok {
+        result.map(|closure| {
+            let pos = closure.body.pos;
+
+            Closure {
+                body: mk_term::enum_variant("Ok", closure.body).with_pos(pos),
+                ..closure
+            }
+        })
+    } else {
+        result
     }
 }
 
diff --git a/core/src/eval/operation.rs b/core/src/eval/operation.rs
index e0456dbc..3ee4f6a0 100644
--- a/core/src/eval/operation.rs
+++ b/core/src/eval/operation.rs
@@ -21,12 +21,11 @@ use crate::{
     error::{EvalError, IllegalPolymorphicTailAction},
     identifier::LocIdent,
     label::{ty_path, Polarity, TypeVarData},
-    match_sharedterm, mk_app, mk_fun, mk_opn, mk_record,
+    match_sharedterm, mk_app, mk_fun, mk_record,
     parser::utils::parse_number_sci,
     position::TermPos,
     serialize,
     serialize::ExportFormat,
-    stdlib,
     stdlib::internals,
     term::{
         array::{Array, ArrayAttrs, OutOfBoundError},
@@ -181,22 +180,25 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
         let pos_op_inh = pos_op.into_inherited();
 
         macro_rules! mk_type_error {
-            ($primop:expr, $expected:expr) => {
-                EvalError::UnaryPrimopTypeError {
-                    primop: String::from($primop),
+            (op_name=$op_name:expr, $expected:expr) => {
+                Err(EvalError::UnaryPrimopTypeError {
+                    primop: String::from($op_name),
                     expected: String::from($expected),
                     arg_pos,
                     arg_evaluated: RichTerm { term: t, pos },
-                }
+                })
+            };
+            ($expected:expr) => {
+                mk_type_error!(op_name = u_op.to_string(), $expected)
             };
-            ($primop:expr, $expected:expr, $arg_number:expr) => {
-                EvalError::NAryPrimopTypeError {
-                    primop: String::from($primop),
+            ($expected:expr, $arg_number:expr) => {
+                Err(EvalError::NAryPrimopTypeError {
+                    primop: u_op.to_string(),
                     expected: String::from($expected),
                     arg_number: $arg_number,
                     arg_pos,
                     arg_evaluated: RichTerm { term: t, pos },
-                }
+                })
             };
         }
 
@@ -262,7 +264,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             term: t,
                             pos: pos_op_inh,
                         })),
-                        _ => Err(mk_type_error!("(&&)", "Bool", 1)),
+                        _ => mk_type_error!("Bool", 1),
                     }
                 } else {
                     Err(EvalError::NotEnoughArgs(2, String::from("&&"), pos_op))
@@ -281,7 +283,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         // it should be solved only once primary operators have better support for
                         // laziness in some arguments.
                         Term::Bool(false) => Ok(next),
-                        _ => Err(mk_type_error!("(||)", "Bool", 1)),
+                        _ => mk_type_error!("Bool", 1),
                     }
                 } else {
                     Err(EvalError::NotEnoughArgs(2, String::from("||"), pos_op))
@@ -294,7 +296,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("unary negation", "Bool"))
+                    mk_type_error!("Bool")
                 }
             }
             UnaryOp::Blame => match_sharedterm!(match (t) {
@@ -303,7 +305,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     label,
                     call_stack: std::mem::take(&mut self.call_stack),
                 }),
-                _ => Err(mk_type_error!("blame", "Label")),
+                _ => mk_type_error!("Label"),
             }),
             UnaryOp::EnumEmbed(_id) => {
                 if let Term::Enum(_) = &*t {
@@ -312,7 +314,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos: pos_op_inh,
                     }))
                 } else {
-                    Err(mk_type_error!("embed", "Enum"))
+                    mk_type_error!("Enum")
                 }
             }
             UnaryOp::TagsOnlyMatch { has_default } => {
@@ -365,7 +367,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 } else if let Some(clos) = default {
                     Ok(clos)
                 } else {
-                    Err(mk_type_error!("match", "Enum", 2))
+                    mk_type_error!("Enum", 2)
                 }
             }
             UnaryOp::LabelFlipPol => match_sharedterm!(match (t) {
@@ -377,7 +379,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                _ => Err(mk_type_error!("chng_pol", "Label")),
+                _ => mk_type_error!("Label"),
             }),
             UnaryOp::LabelPol => {
                 if let Term::Lbl(l) = &*t {
@@ -386,7 +388,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("polarity", "Label"))
+                    mk_type_error!("Label")
                 }
             }
             UnaryOp::LabelGoDom => match_sharedterm!(match (t) {
@@ -398,7 +400,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                _ => Err(mk_type_error!("go_dom", "Label")),
+                _ => mk_type_error!("Label"),
             }),
             UnaryOp::LabelGoCodom => match_sharedterm!(match (t) {
                 Term::Lbl(l) => {
@@ -409,7 +411,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                _ => Err(mk_type_error!("go_codom", "Label")),
+                _ => mk_type_error!("Label"),
             }),
             UnaryOp::LabelGoArray => match_sharedterm!(match (t) {
                 Term::Lbl(l) => {
@@ -420,7 +422,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                _ => Err(mk_type_error!("go_array", "Label")),
+                _ => mk_type_error!("Label"),
             }),
             UnaryOp::LabelGoDict => match_sharedterm!(match (t) {
                 Term::Lbl(l) => {
@@ -431,7 +433,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                _ => Err(mk_type_error!("go_dict", "Label")),
+                _ => mk_type_error!("Label"),
             }),
             UnaryOp::RecordAccess(id) => {
                 if let Term::Record(record) = &*t {
@@ -491,7 +493,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                _ => Err(mk_type_error!("fields", "Record")),
+                _ => mk_type_error!("Record"),
             }),
             UnaryOp::RecordValues => match_sharedterm!(match (t) {
                 Term::Record(record) => {
@@ -510,13 +512,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         env,
                     })
                 }
-                _ => Err(mk_type_error!("values", "Record")),
+                _ => mk_type_error!("Record"),
             }),
             UnaryOp::ArrayMap => {
-                let (f, ..) = self
-                    .stack
-                    .pop_arg(&self.cache)
-                    .ok_or_else(|| EvalError::NotEnoughArgs(2, String::from("map"), pos_op))?;
+                let (f, ..) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
+                    EvalError::NotEnoughArgs(2, String::from("array/map"), pos_op)
+                })?;
                 match_sharedterm!(match (t) {
                     Term::Array(ts, attrs) => {
                         let f_as_var = f.body.closurize(&mut self.cache, f.env);
@@ -546,23 +547,22 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             env: Environment::new(),
                         })
                     }
-                    _ => Err(mk_type_error!("map", "Array")),
+                    _ => mk_type_error!("Array"),
                 })
             }
             UnaryOp::ArrayGen => {
-                let (f, _) = self
-                    .stack
-                    .pop_arg(&self.cache)
-                    .ok_or_else(|| EvalError::NotEnoughArgs(2, String::from("generate"), pos_op))?;
+                let (f, _) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
+                    EvalError::NotEnoughArgs(2, String::from("array/generate"), pos_op)
+                })?;
 
                 let Term::Num(ref n) = *t else {
-                    return Err(mk_type_error!("generate", "Number"));
+                    return mk_type_error!("Number");
                 };
 
                 if n < &Number::ZERO {
                     return Err(EvalError::Other(
                         format!(
-                            "generate expects its first argument to be a positive number, got {n}"
+                            "array/generate expects its first argument to be a positive number, got {n}"
                         ),
                         pos_op,
                     ));
@@ -571,7 +571,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let Ok(n_int) = u32::try_from(n) else {
                     return Err(EvalError::Other(
                         format!(
-                            "generate expects its first argument to be an integer \
+                            "array/generate expects its first argument to be an integer \
                             smaller than {}, got {n}",
                             u32::MAX,
                         ),
@@ -601,7 +601,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             }
             UnaryOp::RecordMap => {
                 let (f, ..) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
-                    EvalError::NotEnoughArgs(2, String::from("record_map"), pos_op)
+                    EvalError::NotEnoughArgs(2, String::from("record/map"), pos_op)
                 })?;
 
                 match_sharedterm!(match (t) {
@@ -647,7 +647,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             env: Environment::new(),
                         })
                     }
-                    _ => Err(mk_type_error!("record_map", "Record", 1)),
+                    _ => mk_type_error!("Record", 1),
                 })
             }
             UnaryOp::Seq => self
@@ -737,7 +737,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         env: Environment::new(),
                     })
                 } else {
-                    Err(mk_type_error!("length", "Array"))
+                    mk_type_error!("Array")
                 }
             }
             UnaryOp::ChunksConcat => {
@@ -806,7 +806,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("str_trim", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::StringChars => {
@@ -817,7 +817,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("str_chars", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::StringUppercase => {
@@ -827,7 +827,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("str_uppercase", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::StringLowercase => {
@@ -837,7 +837,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("string_lowercase", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::StringLength => {
@@ -848,7 +848,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("str_length", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::ToString => {
@@ -873,7 +873,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 if let Term::Str(s) = &*t {
                     let n = parse_number_sci(s).map_err(|_| {
                         EvalError::Other(
-                            format!("num_from_string: invalid number literal `{}`", s.as_str()),
+                            format!(
+                                "number/from_string: invalid number literal `{}`",
+                                s.as_str()
+                            ),
                             pos,
                         )
                     })?;
@@ -882,7 +885,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("num_from_str", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::EnumFromString => {
@@ -892,7 +895,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!("enum_from_str", "String"))
+                    mk_type_error!("String")
                 }
             }
             UnaryOp::StringIsMatch => {
@@ -914,7 +917,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                     Ok(Closure::atomic_closure(RichTerm::new(matcher, pos)))
                 } else {
-                    Err(mk_type_error!("str_is_match", "String", 1))
+                    mk_type_error!("String", 1)
                 }
             }
             UnaryOp::StringFind => {
@@ -936,7 +939,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                     Ok(Closure::atomic_closure(RichTerm::new(matcher, pos)))
                 } else {
-                    Err(mk_type_error!("str_find", "String", 1))
+                    mk_type_error!("String", 1)
                 }
             }
             UnaryOp::StringFindAll => {
@@ -958,7 +961,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                     Ok(Closure::atomic_closure(RichTerm::new(matcher, pos_op_inh)))
                 } else {
-                    Err(mk_type_error!("str_find_all", "String", 1))
+                    mk_type_error!("String", 1)
                 }
             }
             UnaryOp::StringIsMatchCompiled(regex) => {
@@ -968,10 +971,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 } else {
-                    Err(mk_type_error!(
-                        "a compiled regular expression match",
-                        "String"
-                    ))
+                    mk_type_error!(op_name = "a compiled regular expression match", "String")
                 }
             }
             UnaryOp::StringFindCompiled(regex) => {
@@ -1009,10 +1009,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     };
                     Ok(Closure::atomic_closure(result))
                 } else {
-                    Err(mk_type_error!(
-                        "a compiled regular expression match",
-                        "String"
-                    ))
+                    mk_type_error!(op_name = "a compiled regular expression match", "String")
                 }
             }
             UnaryOp::StringFindAllCompiled(regex) => {
@@ -1038,10 +1035,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                     Ok(Closure::atomic_closure(RichTerm::new(result, pos_op_inh)))
                 } else {
-                    Err(mk_type_error!(
-                        "a compiled regular expression match",
-                        "String"
-                    ))
+                    mk_type_error!(op_name = "a compiled regular expression match", "String")
                 }
             }
             UnaryOp::Force {
@@ -1168,14 +1162,14 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         env,
                     })
                 }
-                _ => Err(mk_type_error!("record_empty_with_tail", "Record")),
+                _ => mk_type_error!("Record"),
             }),
             UnaryOp::Trace => {
                 if let Term::Str(s) = &*t {
                     let _ = writeln!(self.trace, "std.trace: {s}");
                     Ok(())
                 } else {
-                    Err(mk_type_error!("trace", "String"))
+                    mk_type_error!("String")
                 }?;
 
                 self.stack
@@ -1193,7 +1187,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             env,
                         })
                     }
-                    _ => Err(mk_type_error!("label_push_diag", "Label")),
+                    _ => mk_type_error!("Label"),
                 })
             }
             #[cfg(feature = "nix-experimental")]
@@ -1227,12 +1221,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         env,
                     })
                 } else {
-                    Err(mk_type_error!("enum_unwrap_variant", "Enum variant"))
+                    mk_type_error!("Enum variant")
                 }
             }
             UnaryOp::EnumMakeVariant => {
                 let Term::Str(tag) = &*t else {
-                    return Err(mk_type_error!("enum/make_variant", "String"));
+                    return mk_type_error!("String");
                 };
 
                 let (arg_clos, _) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
@@ -1254,7 +1248,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 Term::EnumVariant { tag, .. } | Term::Enum(tag) => Ok(Closure::atomic_closure(
                     RichTerm::new(Term::Enum(*tag), pos_op_inh),
                 )),
-                _ => Err(mk_type_error!("enum_get_tag", "Enum")),
+                _ => mk_type_error!("Enum"),
             },
             UnaryOp::EnumIsVariant => {
                 let result = matches!(&*t, Term::EnumVariant { .. });
@@ -1303,59 +1297,21 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                         Ok(cont)
                     }
-                    _ => Err(mk_type_error!("with_env", "Record")),
+                    _ => mk_type_error!("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"
-                )),
-            },
+            UnaryOp::ContractCustom => {
+                let contract = if let Term::Fun(..) | Term::Match(_) = &*t {
+                    RichTerm { term: t, pos }.closurize(&mut self.cache, env)
+                } else {
+                    return mk_type_error!("Function or MatchExpression");
+                };
+
+                Ok(Closure::atomic_closure(RichTerm::new(
+                    Term::CustomContract(contract),
+                    pos_op_inh,
+                )))
+            }
         }
     }
 
@@ -1389,9 +1345,9 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
         let pos_op_inh = pos_op.into_inherited();
 
         macro_rules! mk_type_error {
-            ($primop:expr, $expected:expr, $arg_number:expr, $term:expr, $pos:expr) => {
-                EvalError::NAryPrimopTypeError {
-                    primop: String::from($primop),
+            (op_name=$op_name:expr, $expected:expr, $arg_number:expr, $term:expr, $pos:expr) => {
+                Err(EvalError::NAryPrimopTypeError {
+                    primop: String::from($op_name),
                     expected: String::from($expected),
                     arg_number: $arg_number,
                     arg_pos: {
@@ -1405,7 +1361,16 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         term: $term,
                         pos: $pos,
                     },
-                }
+                })
+            };
+            ($expected:expr, $arg_number:expr, $term:expr, $pos:expr) => {
+                mk_type_error!(
+                    op_name = b_op.to_string(),
+                    $expected,
+                    $arg_number,
+                    $term,
+                    $pos
+                )
             };
         }
 
@@ -1418,10 +1383,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                 .with_pos(pos_op_inh),
                         ))
                     } else {
-                        Err(mk_type_error!("seal", "Label", 2, t2, pos2))
+                        mk_type_error!("Label", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("seal", "SealingKey", 1, t1, pos1))
+                    mk_type_error!("SealingKey", 1, t1, pos1)
                 }
             }
             BinaryOp::Plus => {
@@ -1432,10 +1397,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(+)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(+)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::Sub => {
@@ -1446,10 +1411,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(-)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(-)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::Mult => {
@@ -1460,10 +1425,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(*)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(*)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::Div => {
@@ -1478,19 +1443,19 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             )))
                         }
                     } else {
-                        Err(mk_type_error!("(/)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(/)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::Modulo => {
                 let Term::Num(ref n1) = *t1 else {
-                    return Err(mk_type_error!("(%)", "Number", 1, t1, pos1));
+                    return mk_type_error!("Number", 1, t1, pos1);
                 };
 
                 let Term::Num(ref n2) = *t2 else {
-                    return Err(mk_type_error!("(%)", "Number", 2, t2, pos2));
+                    return mk_type_error!("Number", 2, t2, pos2);
                 };
 
                 if n2 == &Number::ZERO {
@@ -1542,10 +1507,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("pow", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("pow", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::StringConcat => {
@@ -1557,81 +1522,111 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(++)", "String", 2, t2, pos2))
+                        mk_type_error!("String", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(++)", "String", 1, t1, pos1))
+                    mk_type_error!("String", 1, t1, pos1)
+                }
+            }
+            BinaryOp::ContractApply | BinaryOp::ContractApplyAsCustom => {
+                // The translation of a type might return any kind of contract, including e.g.
+                // a record or a custom contract. The result thus needs to be passed to
+                // `ContractApply` or `ContractApplyAsCustom` again. In that case, we don't bother
+                // tracking the argument and updating the label: this will be done by the next call
+                // to `contract/apply(_as_custom)`.
+                if let Term::Type(typ) = &*t1 {
+                    return Ok(Closure {
+                        body: mk_term::op2(
+                            b_op,
+                            typ.contract()?,
+                            RichTerm {
+                                term: t2,
+                                pos: pos2,
+                            },
+                        )
+                        .with_pos(pos1),
+                        env: env1,
+                    });
                 }
-            }
-            BinaryOp::ContractApply => {
-                if let Term::Lbl(l) = &*t2 {
+
+                let t2 = t2.into_owned();
+
+                if let Term::Lbl(mut label) = t2 {
                     // Track the contract argument for better error reporting, and push back the
                     // label on the stack, so that it becomes the first argument of the contract.
                     let idx = self.stack.track_arg(&mut self.cache).ok_or_else(|| {
-                        EvalError::NotEnoughArgs(3, String::from("apply_contract"), pos_op)
+                        EvalError::NotEnoughArgs(3, String::from("contract/apply"), pos_op)
                     })?;
-                    let mut l = l.clone();
-                    l.arg_pos = self.cache.get_then(idx.clone(), |c| c.body.pos);
-                    l.arg_idx = Some(idx);
 
+                    label.arg_pos = self.cache.get_then(idx.clone(), |c| c.body.pos);
+                    label.arg_idx = Some(idx);
+
+                    // We push the label back on the stack and we properly convert the
+                    // contract to a naked function of a label and a value, which will have the
+                    // stack in the same state as if it had been applied normally to both
+                    // arguments.
                     self.stack.push_arg(
-                        Closure::atomic_closure(RichTerm::new(Term::Lbl(l), pos2.into_inherited())),
+                        Closure::atomic_closure(RichTerm::new(
+                            Term::Lbl(label.clone()),
+                            pos2.into_inherited(),
+                        )),
                         pos2.into_inherited(),
                     );
 
                     match &*t1 {
-                        Term::Type(typ) => Ok(Closure {
-                            body: typ.contract()?,
-                            env: env1,
-                        }),
-                        Term::Fun(..) | Term::Match { .. } => Ok(Closure {
-                            body: RichTerm {
+                        Term::Fun(..) | Term::Match { .. } => {
+                            let as_naked = RichTerm {
                                 term: t1,
                                 pos: pos1,
-                            },
-                            env: env1,
-                        }),
-                        Term::CustomContract(..) => Ok(Closure {
-                            body: mk_app!(
-                                internals::prepare_contract(),
+                            };
+
+                            let body = if let BinaryOp::ContractApply = b_op {
+                                as_naked
+                            } else {
+                                mk_app!(internals::naked_to_custom(), as_naked).with_pos(pos1)
+                            };
+
+                            Ok(Closure { body, env: env1 })
+                        }
+                        Term::CustomContract(ctr) => {
+                            let body = if let BinaryOp::ContractApply = b_op {
+                                mk_app!(internals::prepare_custom_contract(), ctr.clone())
+                                    .with_pos(pos1)
+                            } else {
+                                ctr.clone()
+                            };
+
+                            Ok(Closure { body, env: env1 })
+                        }
+                        Term::Record(..) => {
+                            let pos1_inh = pos1.into_inherited();
+
+                            // Convert the record to the builtin contract implementation
+                            // `$record_contract <record>`.
+                            let as_custom = mk_app!(
+                                internals::record_contract(),
                                 RichTerm {
                                     term: t1,
                                     pos: pos1
                                 }
                             )
-                            .with_pos(pos1),
-                            env: env1,
-                        }),
-                        Term::Record(..) => {
-                            let closurized = RichTerm {
-                                term: t1,
-                                pos: pos1,
-                            }
-                            .closurize(&mut self.cache, env1);
-
-                            // Convert the record to the function `fun l x => MergeContract l x t1
-                            // contract`.
-                            let body = mk_fun!(
-                                "l",
-                                "x",
-                                mk_opn!(
-                                    NAryOp::MergeContract,
-                                    mk_term::var("l"),
-                                    mk_term::var("x"),
-                                    closurized
-                                )
-                            )
-                            .with_pos(pos1.into_inherited());
+                            .with_pos(pos1_inh);
 
-                            Ok(Closure {
-                                body,
-                                env: Environment::new(),
-                            })
+                            let body = if let BinaryOp::ContractApply = b_op {
+                                // Convert the record to the builtin contract implementation
+                                // `$prepare_custom_contract ($record_contract <record>)`.
+                                mk_app!(internals::prepare_custom_contract(), as_custom)
+                                    .with_pos(pos1_inh)
+                            } else {
+                                as_custom
+                            };
+
+                            Ok(Closure { body, env: env1 })
                         }
-                        _ => Err(mk_type_error!("apply_contract", "Contract", 1, t1, pos1)),
+                        _ => mk_type_error!("Contract", 1, t1, pos1),
                     }
                 } else {
-                    Err(mk_type_error!("apply_contract", "Label", 2, t2, pos2))
+                    mk_type_error!("Label", 2, t2.into(), pos2)
                 }
             }
             BinaryOp::Unseal => {
@@ -1652,7 +1647,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         Closure::atomic_closure(mk_term::id())
                     })
                 } else {
-                    Err(mk_type_error!("unseal", "SealingKey", 1, t1, pos1))
+                    mk_type_error!("SealingKey", 1, t1, pos1)
                 }
             }
             BinaryOp::Eq => {
@@ -1712,10 +1707,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(<)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(<)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::LessOrEq => {
@@ -1726,10 +1721,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(<=)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(<=)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::GreaterThan => {
@@ -1740,10 +1735,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(>)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(>)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::GreaterOrEq => {
@@ -1754,10 +1749,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("(>=)", "Number", 2, t2, pos2))
+                        mk_type_error!("Number", 2, t2, pos2)
                     }
                 } else {
-                    Err(mk_type_error!("(>=)", "Number", 1, t1, pos1))
+                    mk_type_error!("Number", 1, t1, pos1)
                 }
             }
             BinaryOp::LabelGoField => match_sharedterm!(match (t1) {
@@ -1770,9 +1765,9 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     }
-                    _ => Err(mk_type_error!("go_field", "Label", 2, t2, pos2)),
+                    _ => mk_type_error!("Label", 2, t2, pos2),
                 }),
-                _ => Err(mk_type_error!("go_field", "String", 1, t1, pos1)),
+                _ => mk_type_error!("String", 1, t1, pos1),
             }),
             BinaryOp::RecordGet => {
                 match_sharedterm!(match (t1) {
@@ -1831,7 +1826,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     // This error should be impossible to trigger. The parser
                     // prevents a dynamic field access where the field name is not syntactically
                     // a string.
-                    _ => Err(mk_type_error!(".$", "String", 1, t1, pos1)),
+                    _ => mk_type_error!("String", 1, t1, pos1),
                 })
             }
             BinaryOp::RecordInsert {
@@ -1875,7 +1870,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                 {
                                     Err(EvalError::Other(
                                         format!(
-                                            "record_insert: \
+                                            "record/insert: \
                                             tried to extend a record with the field {id}, \
                                             but it already exists"
                                         ),
@@ -1888,10 +1883,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                 }),
                             }
                         }
-                        _ => Err(mk_type_error!("record_insert", "Record", 2, t2, pos2)),
+                        _ => mk_type_error!(op_name = "record/insert", "Record", 2, t2, pos2),
                     })
                 } else {
-                    Err(mk_type_error!("record_insert", "String", 1, t1, pos1))
+                    mk_type_error!(op_name = "record/insert", "String", 1, t1, pos1)
                 }
             }
             BinaryOp::RecordRemove(op_kind) => match_sharedterm!(match (t1) {
@@ -1931,7 +1926,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                     Err(EvalError::FieldMissing {
                                         id: id.into(),
                                         field_names: record.field_names(op_kind),
-                                        operator: String::from("record_remove"),
+                                        operator: String::from("record/remove"),
                                         pos_record: pos2,
                                         pos_op,
                                     })
@@ -1947,9 +1942,9 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             })
                         }
                     }
-                    _ => Err(mk_type_error!("record_remove", "Record", 2, t2, pos2)),
+                    _ => mk_type_error!("Record", 2, t2, pos2),
                 }),
-                _ => Err(mk_type_error!("record_remove", "String", 1, t1, pos1)),
+                _ => mk_type_error!("String", 1, t1, pos1),
             }),
             BinaryOp::RecordHasField(op_kind) => match_sharedterm!(match (t1) {
                 Term::Str(id) => {
@@ -1962,10 +1957,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("has_field", "Record", 2, t2, pos2))
+                        mk_type_error!("Record", 2, t2, pos2)
                     }
                 }
-                _ => Err(mk_type_error!("has_field", "String", 1, t1, pos1)),
+                _ => mk_type_error!("String", 1, t1, pos1),
             }),
             BinaryOp::RecordFieldIsDefined(op_kind) => match_sharedterm!(match (t1) {
                 Term::Str(id) => {
@@ -1978,10 +1973,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("field_is_defined", "Record", 2, t2, pos2))
+                        mk_type_error!("Record", 2, t2, pos2)
                     }
                 }
-                _ => Err(mk_type_error!("field_is_defined", "String", 1, t1, pos1)),
+                _ => mk_type_error!("String", 1, t1, pos1),
             }),
             BinaryOp::ArrayConcat => match_sharedterm!(match (t1) {
                 Term::Array(ts1, attrs1) => match_sharedterm!(match (t2) {
@@ -2098,11 +2093,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         })
                     }
                     _ => {
-                        Err(mk_type_error!("(@)", "Array", 2, t2, pos2))
+                        mk_type_error!("Array", 2, t2, pos2)
                     }
                 }),
                 _ => {
-                    Err(mk_type_error!("(@)", "Array", 1, t1, pos1))
+                    mk_type_error!("Array", 1, t1, pos1)
                 }
             }),
             BinaryOp::ArrayAt => match (&*t1, &*t2) {
@@ -2110,7 +2105,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     let Ok(n_as_usize) = usize::try_from(n) else {
                         return Err(EvalError::Other(
                             format!(
-                                "elem_at expects its second argument to be a \
+                                "array/at expects its second argument to be a \
                                 positive integer smaller than {}, got {n}",
                                 usize::MAX
                             ),
@@ -2121,7 +2116,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     if n_as_usize >= ts.len() {
                         return Err(EvalError::Other(
                             format!(
-                                "elem_at: index out of bounds. \
+                                "array/at: index out of bounds. \
                                 Expected an index between 0 and {}, got {}",
                                 ts.len(),
                                 n
@@ -2141,8 +2136,8 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         env: env1,
                     })
                 }
-                (Term::Array(..), _) => Err(mk_type_error!("elem_at", "Number", 2, t2, pos2)),
-                (_, _) => Err(mk_type_error!("elem_at", "Array", 1, t1, pos1)),
+                (Term::Array(..), _) => mk_type_error!("Number", 2, t2, pos2),
+                (_, _) => mk_type_error!("Array", 1, t1, pos1),
             },
             BinaryOp::Merge(merge_label) => merge::merge(
                 &mut self.cache,
@@ -2161,15 +2156,8 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 &mut self.call_stack,
             ),
             BinaryOp::Hash => {
-                let mk_err_fst = |t1| {
-                    Err(mk_type_error!(
-                        "hash",
-                        "[| 'Md5, 'Sha1, 'Sha256, 'Sha512 |]",
-                        1,
-                        t1,
-                        pos1
-                    ))
-                };
+                let mk_err_fst =
+                    |t1| mk_type_error!("[| 'Md5, 'Sha1, 'Sha256, 'Sha512 |]", 1, t1, pos1);
 
                 if let Term::Enum(id) = &*t1 {
                     if let Term::Str(s) = &*t2 {
@@ -2202,22 +2190,14 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     } else {
-                        Err(mk_type_error!("hash", "String", 2, t2, pos2))
+                        mk_type_error!("String", 2, t2, pos2)
                     }
                 } else {
                     mk_err_fst(t1)
                 }
             }
             BinaryOp::Serialize => {
-                let mk_err_fst = |t1| {
-                    Err(mk_type_error!(
-                        "serialize",
-                        "[| 'Json, 'Yaml, 'Toml |]",
-                        1,
-                        t1,
-                        pos1
-                    ))
-                };
+                let mk_err_fst = |t1| mk_type_error!("[| 'Json, 'Yaml, 'Toml |]", 1, t1, pos1);
 
                 if let Term::Enum(ref id) = t1.as_ref() {
                     // Serialization needs all variables term to be fully substituted
@@ -2249,15 +2229,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 }
             }
             BinaryOp::Deserialize => {
-                let mk_err_fst = |t1| {
-                    Err(mk_type_error!(
-                        "deserialize",
-                        "[| 'Json, 'Yaml, 'Toml |]",
-                        1,
-                        t1,
-                        pos1
-                    ))
-                };
+                let mk_err_fst = |t1| mk_type_error!("[| 'Json, 'Yaml, 'Toml |]", 1, t1, pos1);
 
                 if let Term::Enum(id) = &*t1 {
                     if let Term::Str(s) = &*t2 {
@@ -2288,7 +2260,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                         Ok(Closure::atomic_closure(rt.with_pos(pos_op_inh)))
                     } else {
-                        Err(mk_type_error!("deserialize", "String", 2, t2, pos2))
+                        mk_type_error!("String", 2, t2, pos2)
                     }
                 } else {
                     mk_err_fst(t1)
@@ -2302,8 +2274,8 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                (Term::Str(_), _) => Err(mk_type_error!("str_split", "String", 2, t2, pos2)),
-                (_, _) => Err(mk_type_error!("str_split", "String", 1, t1, pos1)),
+                (Term::Str(_), _) => mk_type_error!("String", 2, t2, pos2),
+                (_, _) => mk_type_error!("String", 1, t1, pos1),
             },
             BinaryOp::StringContains => match (&*t1, &*t2) {
                 (Term::Str(s1), Term::Str(s2)) => {
@@ -2313,12 +2285,30 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos_op_inh,
                     )))
                 }
-                (Term::Str(_), _) => Err(mk_type_error!("str_contains", "String", 2, t2, pos2)),
-                (_, _) => Err(mk_type_error!("str_contains", "String", 1, t1, pos1)),
+                (Term::Str(_), _) => mk_type_error!("String", 2, t2, pos2),
+                (_, _) => mk_type_error!("String", 1, t1, pos1),
+            },
+            BinaryOp::StringCompare => match (&*t1, &*t2) {
+                (Term::Str(s1), Term::Str(s2)) => {
+                    use std::cmp::Ordering;
+                    Ok(Closure::atomic_closure(RichTerm::new(
+                        Term::Enum(LocIdent::new_with_pos(
+                            match s1.cmp(s2) {
+                                Ordering::Less => "Lesser",
+                                Ordering::Equal => "Equal",
+                                Ordering::Greater => "Greater",
+                            },
+                            pos_op_inh,
+                        )),
+                        pos_op_inh,
+                    )))
+                }
+                (Term::Str(_), _) => mk_type_error!("String", 2, t2, pos2),
+                (_, _) => mk_type_error!("String", 1, t1, pos1),
             },
             BinaryOp::ContractArrayLazyApp => {
                 let (ctr, _) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
-                    EvalError::NotEnoughArgs(3, String::from("array_lazy_app_ctr"), pos_op)
+                    EvalError::NotEnoughArgs(3, String::from("contract/array_lazy_app"), pos_op)
                 })?;
 
                 let Closure {
@@ -2329,7 +2319,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 // FIXME: use match?
                 let lbl = match_sharedterm!(match (t1) {
                     Term::Lbl(lbl) => lbl,
-                    _ => return Err(mk_type_error!("array_lazy_app_ctr", "Label", 1, t1, pos1)),
+                    _ => return mk_type_error!("Label", 1, t1, pos1),
                 });
 
                 match_sharedterm!(match (t2) {
@@ -2353,7 +2343,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                         Ok(array_with_ctr)
                     }
-                    _ => Err(mk_type_error!("array_lazy_app_ctr", "Array", 2, t2, pos2)),
+                    _ => mk_type_error!("Array", 2, t2, pos2),
                 })
             }
             BinaryOp::ContractRecordLazyApp => {
@@ -2366,12 +2356,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
-                    EvalError::NotEnoughArgs(3, String::from("record_lazy_app_ctr"), pos_op)
+                    EvalError::NotEnoughArgs(3, String::from("contract/record_lazy_app"), pos_op)
                 })?;
 
                 let label = match_sharedterm!(match (t1) {
                     Term::Lbl(label) => label,
-                    _ => return Err(mk_type_error!("record_lazy_app_ctr", "Label", 1, t1, pos1)),
+                    _ => return mk_type_error!("Label", 1, t1, pos1),
                 });
 
                 match_sharedterm!(match (t2) {
@@ -2419,7 +2409,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             env: Environment::new(),
                         })
                     }
-                    _ => Err(mk_type_error!("record_lazy_app_ctr", "Record", 2, t2, pos2)),
+                    _ => mk_type_error!("Record", 2, t2, pos2),
                 })
             }
             BinaryOp::LabelWithMessage => {
@@ -2427,23 +2417,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let t2 = t2.into_owned();
 
                 let Term::Str(message) = t1 else {
-                    return Err(mk_type_error!(
-                        "label_with_message",
-                        "String",
-                        1,
-                        t1.into(),
-                        pos1
-                    ));
+                    return mk_type_error!("String", 1, t1.into(), pos1);
                 };
 
                 let Term::Lbl(label) = t2 else {
-                    return Err(mk_type_error!(
-                        "label_with_message",
-                        "String",
-                        2,
-                        t2.into(),
-                        pos2
-                    ));
+                    return mk_type_error!("String", 2, t2.into(), pos2);
                 };
 
                 Ok(Closure::atomic_closure(RichTerm::new(
@@ -2469,13 +2447,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let t1 = t1_subst.term.into_owned();
 
                 let Term::Array(array, _) = t1 else {
-                    return Err(mk_type_error!(
-                        "label_with_notes",
-                        "Array",
-                        1,
-                        t1.into(),
-                        pos1
-                    ));
+                    return mk_type_error!("Array", 1, t1.into(), pos1);
                 };
 
                 let notes = array
@@ -2486,25 +2458,13 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         if let Term::Str(s) = term {
                             Ok(s.into_inner())
                         } else {
-                            Err(mk_type_error!(
-                                "label_with_notes",
-                                "String",
-                                1,
-                                term.into(),
-                                element.pos
-                            ))
+                            mk_type_error!("String", 1, term.into(), element.pos)
                         }
                     })
                     .collect::<Result<Vec<_>, _>>()?;
 
                 let Term::Lbl(label) = t2 else {
-                    return Err(mk_type_error!(
-                        "label_with_notes",
-                        "Label",
-                        2,
-                        t2.into(),
-                        pos2
-                    ));
+                    return mk_type_error!("Label", 2, t2.into(), pos2);
                 };
 
                 Ok(Closure::atomic_closure(RichTerm::new(
@@ -2517,23 +2477,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let t2 = t2.into_owned();
 
                 let Term::Str(note) = t1 else {
-                    return Err(mk_type_error!(
-                        "label_append_note",
-                        "String",
-                        1,
-                        t1.into(),
-                        pos1
-                    ));
+                    return mk_type_error!("String", 1, t1.into(), pos1);
                 };
 
                 let Term::Lbl(label) = t2 else {
-                    return Err(mk_type_error!(
-                        "label_append_note",
-                        "Label",
-                        2,
-                        t2.into(),
-                        pos2
-                    ));
+                    return mk_type_error!("Label", 2, t2.into(), pos2);
                 };
 
                 Ok(Closure::atomic_closure(RichTerm::new(
@@ -2546,23 +2494,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let t2 = t2.into_owned();
 
                 let Term::SealingKey(key) = t1 else {
-                    return Err(mk_type_error!(
-                        "lookup_type_variable",
-                        "SealingKey",
-                        1,
-                        t1.into(),
-                        pos1
-                    ));
+                    return mk_type_error!("SealingKey", 1, t1.into(), pos1);
                 };
 
                 let Term::Lbl(label) = t2 else {
-                    return Err(mk_type_error!(
-                        "lookup_type_variable",
-                        "Label",
-                        2,
-                        t2.into(),
-                        pos2
-                    ));
+                    return mk_type_error!("Label", 2, t2.into(), pos2);
                 };
 
                 Ok(Closure::atomic_closure(RichTerm::new(
@@ -2575,23 +2511,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let t2 = t2.into_owned();
 
                 let Term::Record(record1) = t1 else {
-                    return Err(mk_type_error!(
-                        "record/split_pair",
-                        "Record",
-                        1,
-                        t1.into(),
-                        pos1
-                    ));
+                    return mk_type_error!("Record", 1, t1.into(), pos1);
                 };
 
                 let Term::Record(record2) = t2 else {
-                    return Err(mk_type_error!(
-                        "record/split_pair",
-                        "Record",
-                        2,
-                        t2.into(),
-                        pos2
-                    ));
+                    return mk_type_error!("Record", 2, t2.into(), pos2);
                 };
 
                 let split::SplitResult {
@@ -2661,36 +2585,33 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let t2 = t2.into_owned();
 
                 let Term::Record(mut record1) = t1 else {
-                    return Err(mk_type_error!(
-                        "record/disjoint_merge",
-                        "Record",
-                        1,
-                        t1.into(),
-                        pos1
-                    ));
+                    return mk_type_error!("Record", 1, t1.into(), pos1);
                 };
 
                 let Term::Record(record2) = t2 else {
-                    return Err(mk_type_error!(
-                        "record/disjoint_merge",
-                        "Record",
-                        2,
-                        t2.into(),
-                        pos2
-                    ));
+                    return mk_type_error!("Record", 2, t2.into(), pos2);
                 };
 
-                // As for merge, we refuse to combine two records if one of them has a sealed tail
-                if let Some(record::SealedTail { label, .. }) =
-                    record1.sealed_tail.or(record2.sealed_tail)
-                {
-                    return Err(EvalError::IllegalPolymorphicTailAccess {
-                        action: IllegalPolymorphicTailAction::Merge,
-                        evaluated_arg: label.get_evaluated_arg(&self.cache),
-                        label,
-                        call_stack: std::mem::take(&mut self.call_stack),
-                    });
-                }
+                // As for merge, we refuse to combine two records if one of them has a sealed tail.
+                // However, if only one of them does, because we don't do any recursive
+                // re-evaluation here, it's fine to just pick this tail as the tail of the result.
+                //
+                // This behavior is actually useful, because disjoint_merge is used in the
+                // implementation of builtin contracts to combine an unsealed tail with the
+                // original body of the record. In that case, the unsealed tail might have an
+                // additional sealed tail itself (tail can be sealed multiple times in a nested
+                // way), and the right behavior is to just keep it.
+                let sealed_tail = match (record1.sealed_tail, record2.sealed_tail) {
+                    (Some(record::SealedTail { label, .. }), Some(_)) => {
+                        return Err(EvalError::IllegalPolymorphicTailAccess {
+                            action: IllegalPolymorphicTailAction::Merge,
+                            evaluated_arg: label.get_evaluated_arg(&self.cache),
+                            label,
+                            call_stack: std::mem::take(&mut self.call_stack),
+                        })
+                    }
+                    (tail1, tail2) => tail1.or(tail2),
+                };
 
                 // Note that because of record closurization, we assume here that the record data
                 // of each record are already closurized, so we don't really care about
@@ -2704,60 +2625,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     Term::Record(RecordData {
                         fields: record1.fields,
                         attrs: record1.attrs,
-                        // We need to set the tail to `None` explicitly to appease the borrow
-                        // checker which objects that `sealed_tail` has been moved in the if
-                        // let above.
-                        sealed_tail: None,
+                        sealed_tail,
                     }),
                     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,
-                )))
-            }
         }
     }
 
@@ -2773,6 +2645,20 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
     ) -> Result<Closure, EvalError> {
         let pos_op_inh = pos_op.into_inherited();
 
+        let mk_type_error = |expected: &str,
+                             arg_number: usize,
+                             arg_pos: TermPos,
+                             term: SharedTerm,
+                             pos: TermPos| {
+            Err(EvalError::NAryPrimopTypeError {
+                primop: n_op.to_string(),
+                expected: expected.to_owned(),
+                arg_number,
+                arg_pos,
+                arg_evaluated: RichTerm { term, pos },
+            })
+        };
+
         // Currently, for fixed arity primitive operators, the parser must ensure that they get
         // exactly the right number of argument: if it is not the case, this is a bug, and we panic.
         match n_op {
@@ -2801,36 +2687,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             pos_op_inh,
                         )))
                     }
-                    (Term::Str(_), Term::Str(_), _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: format!("{n_op}"),
-                        expected: String::from("String"),
-                        arg_number: 3,
-                        arg_pos: thd_pos,
-                        arg_evaluated: RichTerm {
-                            term: thd,
-                            pos: pos3,
-                        },
-                    }),
-                    (Term::Str(_), _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: format!("{n_op}"),
-                        expected: String::from("String"),
-                        arg_number: 2,
-                        arg_pos: snd_pos,
-                        arg_evaluated: RichTerm {
-                            term: snd,
-                            pos: pos2,
-                        },
-                    }),
-                    (_, _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: format!("{n_op}"),
-                        expected: String::from("String"),
-                        arg_number: 1,
-                        arg_pos: fst_pos,
-                        arg_evaluated: RichTerm {
-                            term: fst,
-                            pos: pos1,
-                        },
-                    }),
+                    (Term::Str(_), Term::Str(_), _) => {
+                        mk_type_error("String", 3, thd_pos, thd, pos3)
+                    }
+                    (Term::Str(_), _, _) => mk_type_error("String", 2, snd_pos, snd, pos2),
+                    (_, _, _) => mk_type_error("String", 1, fst_pos, fst, pos1),
                 }
             }
             NAryOp::StringSubstr => {
@@ -2849,40 +2710,16 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             Closure::atomic_closure(RichTerm::new(Term::Str(substr), pos_op_inh))
                         })
                         .map_err(|e| EvalError::Other(format!("{}", e), pos_op)),
-                    (Term::Str(_), Term::Num(_), _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("str_subst"),
-                        expected: String::from("String"),
-                        arg_number: 3,
-                        arg_pos: thd_pos,
-                        arg_evaluated: RichTerm {
-                            term: thd,
-                            pos: pos3,
-                        },
-                    }),
-                    (Term::Str(_), _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("str_subst"),
-                        expected: String::from("String"),
-                        arg_number: 2,
-                        arg_pos: snd_pos,
-                        arg_evaluated: RichTerm {
-                            term: snd,
-                            pos: pos2,
-                        },
-                    }),
-                    (_, _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("str_substr"),
-                        expected: String::from("String"),
-                        arg_number: 1,
-                        arg_pos: fst_pos,
-                        arg_evaluated: RichTerm {
-                            term: fst,
-                            pos: pos1,
-                        },
-                    }),
+                    (Term::Str(_), Term::Num(_), _) => {
+                        mk_type_error("Number", 3, thd_pos, thd, pos3)
+                    }
+                    (Term::Str(_), _, _) => mk_type_error("Number", 2, snd_pos, snd, pos2),
+                    (_, _, _) => mk_type_error("String", 1, fst_pos, fst, pos1),
                 }
             }
             NAryOp::MergeContract => {
                 let mut args_iter = args.into_iter();
+
                 let (
                     Closure {
                         body: RichTerm { term: t1, pos: _ },
@@ -2890,6 +2727,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = args_iter.next().unwrap();
+
                 let (
                     Closure {
                         body:
@@ -2901,6 +2739,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = args_iter.next().unwrap();
+
                 let (
                     Closure {
                         body:
@@ -2912,6 +2751,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = args_iter.next().unwrap();
+
                 debug_assert!(args_iter.next().is_none());
 
                 match_sharedterm!(match (t1) {
@@ -2935,7 +2775,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     }
                     _ => Err(EvalError::InternalError(
                         format!(
-                            "The MergeContract() operator was expecting \
+                            "The {n_op} operator was expecting \
                                 a first argument of type Label, got {}",
                             t1.type_of()
                                 .unwrap_or_else(|| String::from("<unevaluated>"))
@@ -3015,49 +2855,13 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         Ok(Closure { body, env: env3 })
                     }
                     (Term::SealingKey(_), Term::Lbl(_), Term::Record(_), _) => {
-                        Err(EvalError::NAryPrimopTypeError {
-                            primop: String::from("record_seal_tail"),
-                            expected: String::from("Record"),
-                            arg_number: 4,
-                            arg_pos: frth_pos,
-                            arg_evaluated: RichTerm {
-                                term: a4,
-                                pos: pos4,
-                            },
-                        })
+                        mk_type_error("Record", 4, frth_pos, a4, pos4)
                     }
                     (Term::SealingKey(_), Term::Lbl(_), _, _) => {
-                        Err(EvalError::NAryPrimopTypeError {
-                            primop: String::from("record_seal_tail"),
-                            expected: String::from("Record"),
-                            arg_number: 3,
-                            arg_pos: thd_pos,
-                            arg_evaluated: RichTerm {
-                                term: a3,
-                                pos: pos3,
-                            },
-                        })
+                        mk_type_error("Record", 3, thd_pos, a3, pos3)
                     }
-                    (Term::SealingKey(_), _, _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("record_seal_tail"),
-                        expected: String::from("Label"),
-                        arg_number: 2,
-                        arg_pos: snd_pos,
-                        arg_evaluated: RichTerm {
-                            term: a2,
-                            pos: pos2,
-                        },
-                    }),
-                    (_, _, _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("record_seal_tail"),
-                        expected: String::from("SealingKey"),
-                        arg_number: 1,
-                        arg_pos: fst_pos,
-                        arg_evaluated: RichTerm {
-                            term: a1,
-                            pos: pos1,
-                        },
-                    }),
+                    (Term::SealingKey(_), _, _, _) => mk_type_error("Label", 2, snd_pos, a2, pos2),
+                    (_, _, _, _) => mk_type_error("SealingKey", 1, fst_pos, a1, pos1),
                 }
             }
             NAryOp::RecordUnsealTail => {
@@ -3108,36 +2912,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             call_stack: std::mem::take(&mut self.call_stack),
                         })
                         .map(|t| Closure { body: t, env: env3 }),
-                    (Term::SealingKey(_), Term::Lbl(_), _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("record_unseal_tail"),
-                        expected: String::from("Record"),
-                        arg_number: 3,
-                        arg_pos: thd_pos,
-                        arg_evaluated: RichTerm {
-                            term: a3,
-                            pos: pos3,
-                        },
-                    }),
-                    (Term::SealingKey(_), _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("record_unseal_tail"),
-                        expected: String::from("Label"),
-                        arg_number: 2,
-                        arg_pos: snd_pos,
-                        arg_evaluated: RichTerm {
-                            term: a2,
-                            pos: pos2,
-                        },
-                    }),
-                    (_, _, _) => Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("record_unseal_tail"),
-                        expected: String::from("SealingKey"),
-                        arg_number: 1,
-                        arg_pos: fst_pos,
-                        arg_evaluated: RichTerm {
-                            term: a1,
-                            pos: pos1,
-                        },
-                    }),
+                    (Term::SealingKey(_), Term::Lbl(_), _) => {
+                        mk_type_error("Record", 3, thd_pos, a3, pos3)
+                    }
+                    (Term::SealingKey(_), _, _) => mk_type_error("Label", 2, snd_pos, a2, pos2),
+                    (_, _, _) => mk_type_error("SealingKey", 1, fst_pos, a1, pos1),
                 }
             }
             NAryOp::LabelInsertTypeVar => {
@@ -3181,42 +2960,15 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 debug_assert!(args.next().is_none());
 
                 let Term::SealingKey(key) = *key else {
-                    return Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("insert_type_variable"),
-                        expected: String::from("SealingKey"),
-                        arg_number: 1,
-                        arg_pos: key_pos,
-                        arg_evaluated: RichTerm {
-                            term: key,
-                            pos: pos1,
-                        },
-                    });
+                    return mk_type_error("SealingKey", 1, key_pos, key, pos1);
                 };
 
                 let Ok(polarity) = Polarity::try_from(polarity.as_ref()) else {
-                    return Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("insert_type_variable"),
-                        expected: String::from("Polarity"),
-                        arg_number: 2,
-                        arg_pos: polarity_pos,
-                        arg_evaluated: RichTerm {
-                            term: polarity,
-                            pos: pos2,
-                        },
-                    });
+                    return mk_type_error("Polarity", 2, polarity_pos, polarity, pos2);
                 };
 
                 let Term::Lbl(label) = &*label else {
-                    return Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("insert_type_variable"),
-                        expected: String::from("Label"),
-                        arg_number: 3,
-                        arg_pos: label_pos,
-                        arg_evaluated: RichTerm {
-                            term: label,
-                            pos: pos3,
-                        },
-                    });
+                    return mk_type_error("Label", 3, label_pos, label, pos3);
                 };
 
                 let mut new_label = label.clone();
@@ -3270,47 +3022,23 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 debug_assert!(args.next().is_none());
 
                 let Term::Num(ref start) = &*t1 else {
-                    return Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("array_slice"),
-                        expected: String::from("Number"),
-                        arg_number: 1,
-                        arg_pos: fst_pos,
-                        arg_evaluated: RichTerm {
-                            term: t1,
-                            pos: pos1,
-                        },
-                    });
+                    return mk_type_error("Number", 1, fst_pos, t1, pos1);
                 };
 
                 let Term::Num(ref end) = &*t2 else {
-                    return Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("array_slice"),
-                        expected: String::from("Number"),
-                        arg_number: 2,
-                        arg_pos: snd_pos,
-                        arg_evaluated: RichTerm {
-                            term: t2,
-                            pos: pos2,
-                        },
-                    });
+                    return mk_type_error("Number", 2, snd_pos, t2, pos2);
                 };
 
                 let t3_owned = t3.into_owned();
 
                 let Term::Array(mut array, attrs) = t3_owned else {
-                    return Err(EvalError::NAryPrimopTypeError {
-                        primop: String::from("array_slice"),
-                        expected: String::from("Array"),
-                        arg_number: 3,
-                        arg_pos: third_pos,
-                        arg_evaluated: RichTerm::new(t3_owned, pos3),
-                    });
+                    return mk_type_error("Array", 3, third_pos, t3_owned.into(), pos3);
                 };
 
                 let Ok(start_as_usize) = usize::try_from(start) else {
                     return Err(EvalError::Other(
                         format!(
-                            "array_slice expects its first argument (start) to be a \
+                            "array/slice expects its first argument (start) to be a \
                             positive integer smaller than {}, got {start}",
                             usize::MAX
                         ),
@@ -3321,7 +3049,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 let Ok(end_as_usize) = usize::try_from(end) else {
                     return Err(EvalError::Other(
                         format!(
-                            "array_slice expects its second argument (end) to be a \
+                            "array/slice expects its second argument (end) to be a \
                             positive integer smaller than {}, got {end}",
                             usize::MAX
                         ),
@@ -3334,7 +3062,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 if let Err(OutOfBoundError) = result {
                     return Err(EvalError::Other(
                         format!(
-                            "array_slice: index out of bounds. Expected `start <= end <= {}`, but \
+                            "array/slice: index out of bounds. Expected `start <= end <= {}`, but \
                             got `start={start}` and `end={end}`.",
                             array.len()
                         ),
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index 9528817e..ea8ffd9f 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -1127,8 +1127,7 @@ 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,
+    "contract/custom" => UnaryOp::ContractCustom,
 }
 
 PatternGuard: RichTerm = "if" <Term> => <>;
@@ -1299,9 +1298,9 @@ InfixExpr: UniTerm = {
 
 BOpPre: BinaryOp = {
     "contract/apply" => BinaryOp::ContractApply,
+    "contract/apply_as_custom" => BinaryOp::ContractApplyAsCustom,
     "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,
@@ -1316,6 +1315,7 @@ BOpPre: BinaryOp = {
     "pow" => BinaryOp::Pow,
     "string/split" => BinaryOp::StringSplit,
     "string/contains" => BinaryOp::StringContains,
+    "string/compare" => BinaryOp::StringCompare,
     "record/insert" => BinaryOp::RecordInsert {
         ext_kind: RecordExtKind::WithValue,
         metadata: Default::default(),
@@ -1353,6 +1353,8 @@ NOpPre<ArgRule>: UniTerm = {
         UniTerm::from(mk_opn!(NAryOp::LabelInsertTypeVar, key, pol, label)),
     "array/slice" <t1: ArgRule> <t2: ArgRule> <t3: ArgRule> =>
         UniTerm::from(mk_opn!(NAryOp::ArraySlice, t1, t2, t3)),
+    "record/merge_contract" <t1: ArgRule> <t2: ArgRule> <t3: ArgRule> =>
+        UniTerm::from(mk_opn!(NAryOp::MergeContract, t1, t2, t3)),
 }
 
 TypeBuiltin: Type = {
@@ -1515,11 +1517,10 @@ extern {
 
         "typeof" => Token::Normal(NormalToken::Typeof),
         "contract/apply" => Token::Normal(NormalToken::ContractApply),
+        "contract/apply_as_custom" => Token::Normal(NormalToken::ContractApplyAsCustom),
         "contract/array_lazy_app" => Token::Normal(NormalToken::ContractArrayLazyApp),
         "contract/record_lazy_app" => Token::Normal(NormalToken::ContractRecordLazyApp),
         "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),
@@ -1559,6 +1560,7 @@ extern {
         "record/field_is_defined_with_opts" => Token::Normal(NormalToken::RecordFieldIsDefinedWithOpts),
         "record/split_pair" => Token::Normal(NormalToken::RecordSplitPair),
         "record/disjoint_merge" => Token::Normal(NormalToken::RecordDisjointMerge),
+        "record/merge_contract" => Token::Normal(NormalToken::RecordMergeContract),
         "array/map" => Token::Normal(NormalToken::ArrayMap),
         "array/generate" => Token::Normal(NormalToken::ArrayGen),
         "array/at" => Token::Normal(NormalToken::ArrayAt),
@@ -1580,6 +1582,7 @@ extern {
         "string/uppercase" => Token::Normal(NormalToken::StringUppercase),
         "string/lowercase" => Token::Normal(NormalToken::StringLowercase),
         "string/contains" => Token::Normal(NormalToken::StringContains),
+        "string/compare" => Token::Normal(NormalToken::StringCompare),
         "string/replace" => Token::Normal(NormalToken::StringReplace),
         "string/replace_regex" => Token::Normal(NormalToken::StringReplaceRegex),
         "string/is_match" => Token::Normal(NormalToken::StringIsMatch),
diff --git a/core/src/parser/lexer.rs b/core/src/parser/lexer.rs
index f8eed2e7..b661faa4 100644
--- a/core/src/parser/lexer.rs
+++ b/core/src/parser/lexer.rs
@@ -196,16 +196,14 @@ pub enum NormalToken<'input> {
 
     #[token("%contract/apply%")]
     ContractApply,
+    #[token("%contract/apply_as_custom%")]
+    ContractApplyAsCustom,
     #[token("%contract/array_lazy_apply%")]
     ContractArrayLazyApp,
     #[token("%contract/record_lazy_apply%")]
     ContractRecordLazyApp,
     #[token("%contract/custom%")]
     ContractCustom,
-    #[token("%contract/get_immediate%")]
-    ContractGetImmediate,
-    #[token("%contract/get_delayed%")]
-    ContractGetDelayed,
     #[token("%blame%")]
     Blame,
     #[token("%label/flip_polarity%")]
@@ -291,6 +289,8 @@ pub enum NormalToken<'input> {
     RecordSplitPair,
     #[token("%record/disjoint_merge%")]
     RecordDisjointMerge,
+    #[token("%record/merge_contract%")]
+    RecordMergeContract,
 
     #[token("merge")]
     Merge,
@@ -325,6 +325,8 @@ pub enum NormalToken<'input> {
     StringLowercase,
     #[token("%string/contains%")]
     StringContains,
+    #[token("%string/compare%")]
+    StringCompare,
     #[token("%string/replace%")]
     StringReplace,
     #[token("%string/replace_regex%")]
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 14aad074..9a616ff4 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -5,9 +5,6 @@ use crate::parser::lexer::KEYWORDS;
 use crate::term::{
     pattern::*,
     record::{Field, FieldMetadata, RecordData},
-    // Because we use `Term::*`, we need to differentiate `Contract` from `Term::Contract`, so we
-    // alias the latter
-    CustomContract as ContractNode,
     *,
 };
 use crate::typ::*;
@@ -822,27 +819,14 @@ where
             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> <immediate> <delayed>`.
-            CustomContract(ContractNode { immediate, delayed }) => {
-                let arg_or_null = |arg: &Option<RichTerm>| {
-                    docs![
-                        allocator,
-                        allocator.line(),
-                        if let Some(arg) = arg {
-                            allocator.atom(arg)
-                        } else {
-                            allocator.text("null")
-                        }
-                    ]
-                    .nest(2)
-                    .group()
-                };
-
+            // `%contract/custom% <ctr>`.
+            CustomContract(ctr) => {
                 docs![
                     allocator,
                     "%contract/custom%",
-                    arg_or_null(immediate),
-                    arg_or_null(delayed)
+                    docs![allocator, allocator.line(), allocator.atom(ctr),]
+                        .nest(2)
+                        .group(),
                 ]
             }
             FunPattern(pat, body) => allocator.function(allocator.pat_with_parens(pat), body),
diff --git a/core/src/stdlib.rs b/core/src/stdlib.rs
index e4f7d9d0..2b238c5a 100644
--- a/core/src/stdlib.rs
+++ b/core/src/stdlib.rs
@@ -73,7 +73,6 @@ pub mod internals {
     generate_accessor!(bool);
     generate_accessor!(foreign_id);
     generate_accessor!(string);
-    generate_accessor!(fail);
 
     generate_accessor!(array);
     generate_accessor!(array_dyn);
@@ -90,8 +89,8 @@ pub mod internals {
     generate_accessor!(enum_variant);
     generate_accessor!(forall_enum_tail);
 
-    generate_accessor!(record);
-    generate_accessor!(record_extend);
+    generate_accessor!(record_contract);
+    generate_accessor!(record_type);
     generate_accessor!(forall_record_tail);
     generate_accessor!(dyn_tail);
     generate_accessor!(empty_tail);
@@ -102,9 +101,8 @@ pub mod internals {
 
     generate_accessor!(stdlib_contract_equal);
 
-    generate_accessor!(prepare_contract);
-    generate_accessor!(record_immediate);
-    generate_accessor!(record_delayed);
+    generate_accessor!(prepare_custom_contract);
+    generate_accessor!(naked_to_custom);
 
     generate_accessor!(rec_default);
     generate_accessor!(rec_force);
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index 80372205..9c20feab 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -216,10 +216,50 @@ pub enum Term {
     #[serde(skip)]
     Type(Type),
 
-    /// A custom contract. See the documentation of [CustomContract] for more details on the split
-    /// immediate/delayed representation.
+    /// A custom contract. The content must be a function (or function-like terms like a match
+    /// expression) of two arguments: a label and the value to be checked. In particular, it must
+    /// be a weak-head normal form, and this invariant may be relied upon elsewhere in the
+    /// codebase (although it's not the case at the time of writing, to the best of my knowledge).
+    ///
+    /// 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, the custom contract
+    /// node might also include even more metadata.
+    ///
+    /// # Immediate and delayed parts
+    ///
+    /// Custom contracts usually 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` or `'Error {..}`. The immediate part gathers 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.
+    ///
+    /// Note that this is a conceptual distinction. It did happen that we experimented with making
+    /// this distinction explicit, with custom contracts being represented by two different
+    /// functions, one for each part. But this proved to be cumbersome in many ways (both for us
+    /// language developers and for users). Instead, we decided to make custom contracts just one
+    /// function of type `Label -> Dyn -> [| 'Ok Dyn, 'Error {..} |]`, which gives enough
+    /// information to extract the immediate and the delayed part anyway. The delayed part, if any,
+    /// is embedded in the return value of the case `'Ok Dyn`, where the argument is the original
+    /// value with the delayed checks inside.
+    ///
+    /// # Naked functions as custom contracts
+    ///
+    /// Nowadays, using dedicated constructors is the only documented way of creating custom
+    /// contracts: `std.contract.custom`, `std.contract.from_validator`, etc. The requirement to
+    /// use those dedicated constructors is unfortunately a breaking change (prior to Nickel 1.8)
+    /// as custom contracts were written as naked functions before. Using naked functions is
+    /// discouraged and will be deprecated in the future, but `%contract/apply%` still supports
+    /// them.
     #[serde(skip)]
-    CustomContract(CustomContract),
+    CustomContract(RichTerm),
 
     /// A term that couldn't be parsed properly. Used by the LSP to handle partially valid
     /// programs.
@@ -366,45 +406,7 @@ pub enum BindingType {
     Revertible(FieldDeps),
 }
 
-/// A custom contract.
-///
-/// 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)).
-///
-/// # Immediate and delayed contracts
-///
-/// 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 struct CustomContract {
-    /// The immediate part of the contract.
-    pub immediate: Option<RichTerm>,
-    /// The delayed part of the contract.
-    pub delayed: Option<RichTerm>,
-}
+pub struct CustomContract(pub RichTerm);
 
 /// A runtime representation of a contract, as a term and a label ready to be applied via
 /// [BinaryOp::ContractApply].
@@ -1234,9 +1236,9 @@ pub enum UnaryOp {
 
     /// Typecast an enum to a larger enum type.
     ///
-    /// `Embed` is used to upcast enums. For example, if a value `x` has enum type `a | b`, then
-    /// `embed c x` will have enum type `a | b | c`. It only affects typechecking as at runtime
-    /// `embed someId` act like the identity.
+    /// `EnumEmbed` is used to upcast enums. For example, if a value `x` has enum type `a | b`,
+    /// then `%enum/embed% c x` will have enum type `a | b | c`. It only affects typechecking as at
+    /// runtime `%enum/embed% someId` acts like the identity function.
     EnumEmbed(LocIdent),
 
     /// A specialized primop for match when all patterns are enum tags. In that case, instead of
@@ -1257,7 +1259,7 @@ pub enum UnaryOp {
     ///
     /// The mapped function must take two arguments, the name of the field as a string, and the
     /// content of the field. `RecordMap` then replaces the content of each field by the result of
-    /// the function: i.e., `recordMap f {a=2;}` evaluates to `{a=(f "a" 2);}`.
+    /// the function: i.e., `%record/map% f {a=2;}` evaluates to `{a=(f "a" 2);}`.
     RecordMap,
 
     /// Inverse the polarity of a label.
@@ -1443,12 +1445,10 @@ pub enum UnaryOp {
     /// priority annotation.
     RecForce,
 
-    /// Creates an "empty" record with the sealed tail of its [`Term::Record`]
-    /// argument.
+    /// Creates an "empty" record with the sealed tail of its [`Term::Record`] argument.
     ///
-    /// Used in the `$record` contract implementation to ensure that we can
-    /// define a `field_diff` function that preserves the sealed polymorphic
-    /// tail of its argument.
+    /// Used in the `$record` contract implementation to ensure that we can define a `field_diff`
+    /// function that preserves the sealed polymorphic tail of its argument.
     RecordEmptyWithTail,
 
     /// Print a message when encountered during evaluation and proceed with the evaluation of the
@@ -1462,7 +1462,8 @@ pub enum UnaryOp {
     /// contract application.
     LabelPushDiag,
 
-    /// Evaluate a string of nix code into a resulting nickel value. Currently completely (strictly) evaluates the nix code, and must result in a value serializable into JSON.
+    /// Evaluate a string of nix code into a resulting nickel value. Currently completely
+    /// (strictly) evaluates the nix code, and must result in a value serializable into JSON.
     #[cfg(feature = "nix-experimental")]
     EvalNix,
 
@@ -1490,11 +1491,9 @@ pub enum UnaryOp {
     /// used blindly for something else.
     PatternBranch,
 
-    /// Extract the immediate part of a contract.
-    ContractGetImmediate,
-
-    /// Extract the delayed part of a contract.
-    ContractGetDelayed,
+    /// Wrap a contract implementation as a [CustomContract]. You can think of this primop as a
+    /// type constructor for custom contracts.
+    ContractCustom,
 }
 
 impl fmt::Display for UnaryOp {
@@ -1503,8 +1502,8 @@ impl fmt::Display for UnaryOp {
         match self {
             IfThenElse => write!(f, "if_then_else"),
             Typeof => write!(f, "typeof"),
-            BoolAnd => write!(f, "bool/and"),
-            BoolOr => write!(f, "bool/or"),
+            BoolAnd => write!(f, "(&&)"),
+            BoolOr => write!(f, "(||)"),
             BoolNot => write!(f, "bool/not"),
             Blame => write!(f, "blame"),
             EnumEmbed(_) => write!(f, "enum/embed"),
@@ -1556,8 +1555,7 @@ 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"),
+            ContractCustom => write!(f, "contract/custom"),
         }
     }
 }
@@ -1674,8 +1672,30 @@ pub enum BinaryOp {
     /// also accepts contracts as records, which are translated to a function that merge said
     /// contract with its argument. Finally, this operator marks the location of the contract
     /// argument on the stack for better error reporting.
+    ///
+    /// Either the contract raises a blame error, or the primop evaluates to the value returned by
+    /// the contract that can be used in place of the original value.
     ContractApply,
 
+    /// Variant of [Self::ContractApply] which also applies an arbitrary contract to a label
+    /// and a value, but instead of either blaming or returning the value, it has the same return
+    /// value as a custom contract built via [UnaryOp::ContractCustom], that is `[| 'Ok Dyn, 'Error
+    /// {..}|]`. The value returned through `'Ok` can still have lazy blame expressions inside, of
+    /// course.
+    ///
+    /// Put differently, `%contract/apply_as_custom% (%contract/custom% custom) label value` is
+    /// equivalent to `custom label value`, modulo argument tracking. [Self::ContractApplyAsCustom]
+    /// doesn't only work on custom contracts, but on builtin contracts as well.
+    ///
+    /// This operation is useful for contract composition, that is when calling a contract from
+    /// another contract. In theory, one could use [Self::ContractApply], but the caller then needs
+    /// to wrap the result in `'Ok`, and much more importantly, `%contract/apply%` converts all
+    /// immediate errors returned as `'Error` into blame errors. This is not desirable, as blame
+    /// errors can't be caught, which artificially makes the called contract entirely delayed. This
+    /// typically wouldn't play very well with boolean combinators. On the other hand,
+    /// [Self::ContractApplyAsCustom] preserves the immediate/delayed part of the called contract.
+    ContractApplyAsCustom,
+
     /// Unseal a sealed term.
     ///
     /// See [`BinaryOp::Seal`].
@@ -1768,6 +1788,9 @@ pub enum BinaryOp {
     /// Determine if a string is a substring of another one.
     StringContains,
 
+    /// Compare two strings lexicographically.
+    StringCompare,
+
     /// Seal a term with a sealing key (see [`Term::Sealed`]).
     Seal,
 
@@ -1791,10 +1814,6 @@ 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 {
@@ -1812,19 +1831,20 @@ impl fmt::Display for BinaryOp {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         use BinaryOp::*;
         match self {
-            Plus => write!(f, "plus"),
-            Sub => write!(f, "sub"),
-            Mult => write!(f, "mult"),
-            Div => write!(f, "div"),
-            Modulo => write!(f, "modulo"),
+            Plus => write!(f, "(+)"),
+            Sub => write!(f, "(-)"),
+            Mult => write!(f, "(*)"),
+            Div => write!(f, "(/)"),
+            Modulo => write!(f, "(%)"),
             Pow => write!(f, "pow"),
             StringConcat => write!(f, "string/concat"),
-            Eq => write!(f, "eq"),
-            LessThan => write!(f, "less_than"),
-            LessOrEq => write!(f, "less_or_eq"),
-            GreaterThan => write!(f, "greater_than"),
-            GreaterOrEq => write!(f, "greater_or_eq"),
+            Eq => write!(f, "(==)"),
+            LessThan => write!(f, "(<)"),
+            LessOrEq => write!(f, "(<=)"),
+            GreaterThan => write!(f, "(>)"),
+            GreaterOrEq => write!(f, "(>=)"),
             ContractApply => write!(f, "contract/apply"),
+            ContractApplyAsCustom => write!(f, "contract/apply_as_custom"),
             Unseal => write!(f, "unseal"),
             LabelGoField => write!(f, "label/go_field"),
             RecordInsert {
@@ -1850,18 +1870,18 @@ impl fmt::Display for BinaryOp {
             }
             Self::RecordSplitPair => write!(f, "record/split_pair"),
             Self::RecordDisjointMerge => write!(f, "record/disjoint_merge"),
-            ArrayConcat => write!(f, "array/concat"),
+            ArrayConcat => write!(f, "(@)"),
             ArrayAt => write!(f, "array/at"),
-            Merge(_) => write!(f, "merge"),
+            Merge(_) => write!(f, "(&)"),
             Hash => write!(f, "hash"),
             Serialize => write!(f, "serialize"),
             Deserialize => write!(f, "deserialize"),
             StringSplit => write!(f, "string/split"),
             StringContains => write!(f, "string/contains"),
+            StringCompare => write!(f, "string/compare"),
             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"),
@@ -1944,7 +1964,7 @@ impl fmt::Display for NAryOp {
             StringReplace => write!(f, "string/replace"),
             StringReplaceRegex => write!(f, "string/replace_regex"),
             StringSubstr => write!(f, "string/substr"),
-            MergeContract => write!(f, "merge_contract"),
+            MergeContract => write!(f, "record/merge_contract"),
             RecordSealTail => write!(f, "record/seal_tail"),
             RecordUnsealTail => write!(f, "record/unseal_tail"),
             LabelInsertTypeVar => write!(f, "label/insert_type_variable"),
@@ -2131,14 +2151,9 @@ impl Traverse<RichTerm> for RichTerm {
                 let t = t.traverse(f, order)?;
                 RichTerm::new(Term::FunPattern(pat, t), pos)
             }
-            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 { immediate, delayed }),
-                    pos,
-                )
+            Term::CustomContract(t) => {
+                let t = t.traverse(f, order)?;
+                RichTerm::new(Term::CustomContract(t), pos)
             }
             Term::Let(id, t1, t2, attrs) => {
                 let t1 = t1.traverse(f, order)?;
@@ -2332,11 +2347,8 @@ impl Traverse<RichTerm> for RichTerm {
             | Term::FunPattern(_, 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::Sealed(_, t, _)
+            | Term::CustomContract(t) => t.traverse_ref(f, state),
             Term::Let(_, t1, t2, _)
             | Term::LetPattern(_, t1, t2)
             | Term::App(t1, t2)
@@ -2766,6 +2778,26 @@ pub mod make {
         }
         term
     }
+
+    pub fn enum_variant<S, T>(tag: S, arg: T) -> RichTerm
+    where
+        S: Into<LocIdent>,
+        T: Into<RichTerm>,
+    {
+        Term::EnumVariant {
+            tag: tag.into(),
+            arg: arg.into(),
+            attrs: Default::default(),
+        }
+        .into()
+    }
+
+    pub fn custom_contract<T>(contract: T) -> RichTerm
+    where
+        T: Into<RichTerm>,
+    {
+        Term::CustomContract(contract.into()).into()
+    }
 }
 
 #[cfg(test)]
diff --git a/core/src/term/pattern/compile.rs b/core/src/term/pattern/compile.rs
index 88201715..8bdc3ad4 100644
--- a/core/src/term/pattern/compile.rs
+++ b/core/src/term/pattern/compile.rs
@@ -102,7 +102,7 @@ fn remove_from_rest(rest_field: LocIdent, field: LocIdent, bindings_id: LocIdent
 /// More precisely, [with_default_value] generates the following code:
 ///
 /// ```nickel
-/// if !(%field_is_defined% "<field>" record_id) then
+/// if !(%record/field_is_defined% "<field>" record_id) then
 ///   if %record/has_field% "<field>" record_id then
 ///     record_id & { "<field>" = default }
 ///   else
@@ -363,14 +363,14 @@ impl CompilePart for RecordPattern {
     //      - initial accumulator is `%record/insert% "<REST_FIELD>" bindings_id value_id`
     //      >
     //
-    //     # If there is a default value, we must set it before the %field_is_defined% check below,
+    //     # If there is a default value, we must set it before the %record/field_is_defined% check below,
     //     # because the default acts like if the original matched value always have this field
     //     # defined
     //     <if field.default.is_some()>
     //       let value_id = <with_default_value value_id field default> in
     //     <end if>
     //
-    //      if %field_is_defined% field value_id then
+    //      if %record/field_is_defined% field value_id then
     //         <if !field_pat.annotation.is_empty()>
     //           let value_id = value_id & { "<field>" | <field_pat.annotation> } in
     //         <end if>
@@ -429,14 +429,14 @@ impl CompilePart for RecordPattern {
         //  - initial accumulator is `%record/insert% "<REST>" bindings_id value_id`
         // >
         //
-        // # If there is a default value, we must set it before the %field_is_defined% check below,
+        // # If there is a default value, we must set it before the %record/field_is_defined% check below,
         // # because the default acts like if the original matched value always have this field
         // # defined
         // <if field.default.is_some()>
         //   let value_id = <with_default_value value_id field default> in
         // <end if>
         //
-        // if %field_is_defined% field value_id then
+        // if %record/field_is_defined% field value_id then
         //   # If the field is present, we apply the potential contracts coming from user-provided
         //   # annotations before anything else. We just offload the actual work to `&`
         //   <if !field_pat.annotation.is_empty() >
@@ -514,7 +514,7 @@ impl CompilePart for RecordPattern {
                 binding_cont_let
             };
 
-            // %field_is_defined% field value_id
+            // %record/field_is_defined% field value_id
             let has_field = make::op2(
                 BinaryOp::RecordFieldIsDefined(RecordOpKind::ConsiderAllFields),
                 Term::Str(field.label().into()),
@@ -623,7 +623,7 @@ impl CompilePart for RecordPattern {
 impl CompilePart for ArrayPattern {
     // Compilation of an array pattern.
     //
-    // let value_len = %array_length% value_id in
+    // let value_len = %array/length% value_id in
     //
     // <if self.is_open()>
     // if %typeof% value_id == 'Array && value_len >= <self.patterns.len()>
@@ -641,7 +641,7 @@ impl CompilePart for ArrayPattern {
     //       if local_bindings_id == null then
     //         null
     //       else
-    //         let local_value_id = %array_access% <idx> value_id in
+    //         let local_value_id = %array/at% <idx> value_id in
     //         <elem_pat.compile_part(local_value_id, local_bindings_id)>
     //
     //     <end fold>
@@ -673,7 +673,7 @@ impl CompilePart for ArrayPattern {
         //       if local_bindings_id == null then
         //         null
         //       else
-        //         let local_value_id = %array_access% <idx> value_id in
+        //         let local_value_id = %array/at% <idx> value_id in
         //         <self.patterns[idx].compile_part(local_value_id, local_bindings_id)>
         //
         //     <end fold>
@@ -686,7 +686,7 @@ impl CompilePart for ArrayPattern {
                 // <self.patterns[idx].compile_part(local_value_id, local_bindings_id)>
                 let updated_bindings_let = elem_pat.compile_part(local_value_id, local_bindings_id);
 
-                // %array_access% idx value_id
+                // %array/at% idx value_id
                 let extracted_value = make::op2(
                     BinaryOp::ArrayAt,
                     Term::Var(value_id),
@@ -786,7 +786,7 @@ impl CompilePart for ArrayPattern {
 
 impl CompilePart for EnumPattern {
     fn compile_part(&self, value_id: LocIdent, bindings_id: LocIdent) -> RichTerm {
-        // %enum_get_tag% value_id == '<self.tag>
+        // %enum/get_tag% value_id == '<self.tag>
         let tag_matches = make::op2(
             BinaryOp::Eq,
             make::op1(UnaryOp::EnumGetTag, Term::Var(value_id)),
@@ -794,13 +794,13 @@ impl CompilePart for EnumPattern {
         );
 
         if let Some(pat) = &self.pattern {
-            // if %enum_is_variant% value_id && %enum_get_tag% value_id == '<self.tag> then
-            //   let value_id = %enum_unwrap_variant% value_id in
+            // if %enum/is_variant% value_id && %enum/get_tag% value_id == '<self.tag> then
+            //   let value_id = %enum/get_arg% value_id in
             //   <pattern.compile(value_id, bindings_id)>
             // else
             //   null
 
-            // %enum_is_variant% value_id && <tag_matches>
+            // %enum/is_variant% value_id && <tag_matches>
             let if_condition = mk_app!(
                 make::op1(
                     UnaryOp::BoolAnd,
@@ -819,7 +819,7 @@ impl CompilePart for EnumPattern {
                 Term::Null,
             )
         } else {
-            // if %typeof% value_id == 'Enum && !(%enum_is_variant% value_id) && <tag_matches> then
+            // if %typeof% value_id == 'Enum && !(%enum/is_variant% value_id) && <tag_matches> then
             //   bindings_id
             // else
             //   null
@@ -831,7 +831,7 @@ impl CompilePart for EnumPattern {
                 Term::Enum("Enum".into()),
             );
 
-            // !(%enum_is_variant% value_id)
+            // !(%enum/is_variant% value_id)
             let is_enum_tag = make::op1(
                 UnaryOp::BoolNot,
                 make::op1(UnaryOp::EnumIsVariant, Term::Var(value_id)),
diff --git a/core/src/transform/free_vars.rs b/core/src/transform/free_vars.rs
index 2b3beedd..823719c4 100644
--- a/core/src/transform/free_vars.rs
+++ b/core/src/transform/free_vars.rs
@@ -106,18 +106,10 @@ impl CollectFreeVars for RichTerm {
                     free_vars.extend(fresh);
                 }
             }
-            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::Op1(_, t)
+            | Term::Sealed(_, t, _)
+            | Term::EnumVariant { arg: t, .. }
+            | Term::CustomContract(t) => t.collect_free_vars(free_vars),
             Term::OpN(_, ts) => {
                 for t in ts {
                     t.collect_free_vars(free_vars);
diff --git a/core/src/typ.rs b/core/src/typ.rs
index b3ac6ecb..916a2f76 100644
--- a/core/src/typ.rs
+++ b/core/src/typ.rs
@@ -845,7 +845,16 @@ impl Subcontract for Type {
                 s.subcontract(vars.clone(), pol.flip(), sy)?,
                 t.subcontract(vars, pol, sy)?
             ),
-            TypeF::Flat(ref t) => t.clone(),
+            // Note that we do an early return here.
+            //
+            // All builtin contracts needs an additional wrapping as a `CustomContract`: they're
+            // written as a custom contract but they miss the `%contract/custom%` (mostly because
+            // it's nicer to do it just once at the end than littering the internals module with
+            // `%contract/custom%` applications).
+            //
+            // However, in the case of a contract embedded in a type, we don't want to do the
+            // additional wrapping, as `t` should already be a fully constructed contract.
+            TypeF::Flat(ref t) => return Ok(t.clone()),
             TypeF::Var(id) => get_var_contract(&vars, id, self.pos)?,
             TypeF::Forall {
                 ref var,
@@ -916,7 +925,7 @@ impl Subcontract for Type {
             TypeF::Wildcard(_) => internals::dynamic(),
         };
 
-        Ok(ctr)
+        Ok(mk_term::custom_contract(ctr))
     }
 }
 
@@ -978,9 +987,9 @@ impl Subcontract for EnumRows {
         // ```
         // fun l x =>
         //   x |> match {
-        //     'foo => x,
-        //     'bar => x,
-        //     'Baz variant_arg => 'Baz (%apply_contract% T label_arg variant_arg),
+        //     'foo => 'Ok x,
+        //     'bar => 'Ok x,
+        //     'Baz variant_arg => 'Ok ('Baz (%apply_contract% T label_arg variant_arg)),
         //     _ => $enum_fail l
         //   }
         // ```
@@ -1006,16 +1015,13 @@ impl Subcontract for EnumRows {
                             mk_term::var(variant_arg)
                         );
 
-                        Term::EnumVariant {
-                            tag: row.id,
-                            arg,
-                            attrs: Default::default(),
-                        }
-                        .into()
+                        mk_term::enum_variant(row.id, arg)
                     } else {
                         mk_term::var(value_arg)
                     };
 
+                    let body = mk_term::enum_variant("Ok", body);
+
                     let pattern = Pattern {
                         data: PatternData::Enum(EnumPattern {
                             tag: row.id,
@@ -1153,6 +1159,7 @@ impl Subcontract for RecordRows {
             rrows = tail
         }
 
+        let has_tail = !matches!(&rrows.0, RecordRowsF::Empty);
         // Now that we've dealt with the row extends, we just need to
         // work out the tail.
         let tail = match &rrows.0 {
@@ -1165,7 +1172,12 @@ impl Subcontract for RecordRows {
 
         let rec = RichTerm::from(Term::Record(RecordData::with_field_values(fcs)));
 
-        Ok(mk_app!(internals::record(), rec, tail))
+        Ok(mk_app!(
+            internals::record_type(),
+            rec,
+            tail,
+            Term::Bool(has_tail)
+        ))
     }
 }
 
@@ -1319,14 +1331,16 @@ impl Type {
             .subcontract(Environment::new(), Polarity::Positive, &mut sy)
     }
 
-    /// Return the contract corresponding to a type, either as a function or a record. Said
-    /// contract must then be applied using the `ApplyContract` primitive operation.
+    /// Return the contract corresponding to a type. Said contract must then be applied using the
+    /// `ApplyContract` primitive operation.
     pub fn contract(&self) -> Result<RichTerm, UnboundTypeVariableError> {
         let mut sy = 0;
+
         self.subcontract(Environment::new(), Polarity::Positive, &mut sy)
     }
 
-    /// Returns true if this type is a function type, false otherwise.
+    /// Returns true if this type is a function type (including a polymorphic one), false
+    /// otherwise.
     pub fn is_function_type(&self) -> bool {
         match &self.typ {
             TypeF::Forall { body, .. } => body.is_function_type(),
diff --git a/core/src/typecheck/eq.rs b/core/src/typecheck/eq.rs
index 12833514..e6fb49ab 100644
--- a/core/src/typecheck/eq.rs
+++ b/core/src/typecheck/eq.rs
@@ -390,6 +390,18 @@ fn contract_eq_bounded<E: TermEnvironment>(
                 env2,
             ) && r1.attrs.open == r2.attrs.open
         }
+        (RecRecord(r1, dyn_fields, _), Record(r2)) | (Record(r1), RecRecord(r2, dyn_fields, _)) => {
+            dyn_fields.is_empty()
+                && map_eq(
+                    contract_eq_fields,
+                    state,
+                    &r1.fields,
+                    env1,
+                    &r2.fields,
+                    env2,
+                )
+                && r1.attrs.open == r2.attrs.open
+        }
         (RecRecord(r1, dyn_fields1, _), RecRecord(r2, dyn_fields2, _)) =>
         // We only compare records whose field structure is statically known (i.e. without dynamic
         // fields).
@@ -404,7 +416,7 @@ fn contract_eq_bounded<E: TermEnvironment>(
                     &r2.fields,
                     env2,
                 )
-                && r1.attrs == r2.attrs
+                && r1.attrs.open == r2.attrs.open
         }
         (Array(ts1, attrs1), Array(ts2, attrs2)) => {
             ts1.len() == ts2.len()
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index 4c058342..114faa06 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -60,8 +60,8 @@ use crate::{
     identifier::{Ident, LocIdent},
     stdlib as nickel_stdlib,
     term::{
-        record::Field, CustomContract, LabeledType, MatchBranch, RichTerm, StrChunk, Term,
-        Traverse, TraverseOrder, TypeAnnotation,
+        record::Field, LabeledType, MatchBranch, RichTerm, StrChunk, Term, Traverse, TraverseOrder,
+        TypeAnnotation,
     },
     typ::*,
     {mk_uty_arrow, mk_uty_enum, mk_uty_record, mk_uty_record_row},
@@ -1599,18 +1599,8 @@ fn walk<V: TypecheckVisitor>(
         }
         Term::EnumVariant { arg: t, ..}
         | Term::Sealed(_, 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::Op1(_, t)
+        | Term::CustomContract(t) => walk(state, ctxt, visitor, t),
         Term::Op2(_, t1, t2) => {
             walk(state, ctxt.clone(), visitor, t1)?;
             walk(state, ctxt, visitor, t2)
@@ -1917,43 +1907,27 @@ fn check<V: TypecheckVisitor>(
         }
         // [^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.
+        // datastructure holding a function of a specific type.
         //
-        // One can see it as the application of a `custom` constructor:
-        // `%contract/custom% immediate delayed`.
+        // Whether seen as a type constructor, or as equivalent to a standalone function, it's an
+        // introduction rule and thus it should be check.
         //
-        // 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 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 { immediate, delayed }) => {
+        // This rule can't produce a polymorphic type (it produces a `Dyn`, or morally a `Contract`
+        // type, if we had one), so we don't lose much by making it a check rule anyway, as for
+        // e.g. literals.
+        Term::CustomContract(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(),
-                )?;
-            }
-
-            if let Some(delayed) = delayed {
-                check(state, ctxt, visitor, delayed, operation::delayed_type())?;
-            }
-
-            Ok(())
+            check(
+                state,
+                ctxt.clone(),
+                visitor,
+                t,
+                operation::custom_contract_ret_type(),
+            )
         }
         Term::Array(terms, _) => {
             let ty_elts = state.table.fresh_type_uvar(ctxt.var_level);
@@ -2409,45 +2383,43 @@ pub fn subsumption(
     checked: UnifType,
 ) -> Result<(), UnifError> {
     let inferred_inst = instantiate_foralls(state, &mut ctxt, inferred, ForallInst::UnifVar);
-    match (&inferred_inst, &checked) {
-        (
-            UnifType::Concrete {
-                typ: TypeF::Record(rrows),
-                ..
-            },
-            UnifType::Concrete {
-                typ: TypeF::Dict { type_fields, .. },
-                ..
-            },
-        ) => {
-            for row in rrows.iter() {
-                match row {
-                    GenericUnifRecordRowsIteratorItem::Row(a) => {
-                        subsumption(state, ctxt.clone(), a.typ.clone(), *type_fields.clone())?
-                    }
-                    GenericUnifRecordRowsIteratorItem::TailUnifVar { id, .. } =>
-                    // We don't need to perform any variable level checks when unifying a free
-                    // unification variable with a ground type
-                    // We close the tail because there is no garanty that
-                    // { a : Number, b : Number, _ : a?} <= { _ : Number}
-                    {
-                        state
-                            .table
-                            .assign_rrows(id, UnifRecordRows::concrete(RecordRowsF::Empty))
-                    }
-                    GenericUnifRecordRowsIteratorItem::TailConstant(id) => {
-                        Err(UnifError::WithConst {
-                            var_kind: VarKindDiscriminant::RecordRows,
-                            expected_const_id: id,
-                            inferred: checked.clone(),
-                        })?
+    if let (UnifType::Concrete { typ: typ1, .. }, UnifType::Concrete { typ: typ2, .. }) =
+        (&inferred_inst, &checked)
+    {
+        match (typ1, typ2) {
+            (TypeF::Record(rrows), TypeF::Dict { type_fields, .. }) => {
+                for row in rrows.iter() {
+                    match row {
+                        GenericUnifRecordRowsIteratorItem::Row(a) => {
+                            subsumption(state, ctxt.clone(), a.typ.clone(), *type_fields.clone())?
+                        }
+                        GenericUnifRecordRowsIteratorItem::TailUnifVar { id, .. } =>
+                        // We don't need to perform any variable level checks when unifying a free
+                        // unification variable with a ground type
+                        // We close the tail because there is no garanty that
+                        // { a : Number, b : Number, _ : a?} <= { _ : Number}
+                        {
+                            state
+                                .table
+                                .assign_rrows(id, UnifRecordRows::concrete(RecordRowsF::Empty))
+                        }
+                        GenericUnifRecordRowsIteratorItem::TailConstant(id) => {
+                            Err(UnifError::WithConst {
+                                var_kind: VarKindDiscriminant::RecordRows,
+                                expected_const_id: id,
+                                inferred: checked.clone(),
+                            })?
+                        }
+                        _ => (),
                     }
-                    _ => (),
                 }
+                Ok(())
             }
-            Ok(())
+            (TypeF::Array(a), TypeF::Array(b)) => subsumption(state, ctxt, *a.clone(), *b.clone()),
+            (_, _) => checked.unify(inferred_inst, state, &ctxt),
         }
-        (_, _) => checked.unify(inferred_inst, state, &ctxt),
+    } else {
+        checked.unify(inferred_inst, state, &ctxt)
     }
 }
 
diff --git a/core/src/typecheck/operation.rs b/core/src/typecheck/operation.rs
index 3d04d2bb..a4ed4d90 100644
--- a/core/src/typecheck/operation.rs
+++ b/core/src/typecheck/operation.rs
@@ -1,5 +1,6 @@
 //! Typing of primitive operations.
 use super::*;
+use crate::position::TermPos;
 use crate::{
     error::TypecheckError,
     label::{Polarity, TypeVarData},
@@ -270,12 +271,8 @@ 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()),
+        // <custom_contract_type()> -> Dyn
+        UnaryOp::ContractCustom => (custom_contract_type(), mk_uniftype::dynamic()),
     })
 }
 
@@ -305,11 +302,13 @@ 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()),
+        // Ideally: Contract -> Label -> Dyn -> <custom_contract_ret_type()>
+        // Currently: Dyn -> Dyn -> (Dyn -> <custom_contract_ret_type()>)
+        BinaryOp::ContractApplyAsCustom => (
+            mk_uniftype::dynamic(),
+            mk_uniftype::dynamic(),
+            mk_uty_arrow!(mk_uniftype::dynamic(), custom_contract_ret_type()),
+        ),
         // Sym -> Dyn -> Dyn -> Dyn
         BinaryOp::Unseal => (
             mk_uniftype::sym(),
@@ -435,6 +434,12 @@ pub fn get_bop_type(
         BinaryOp::Pow => (mk_uniftype::num(), mk_uniftype::num(), mk_uniftype::num()),
         // Str -> Str -> Bool
         BinaryOp::StringContains => (mk_uniftype::str(), mk_uniftype::str(), mk_uniftype::bool()),
+        // Str -> Str -> <Lesser, Equal, Greater>
+        BinaryOp::StringCompare => (
+            mk_uniftype::str(),
+            mk_uniftype::str(),
+            mk_uty_enum!("Lesser", "Equal", "Greater"),
+        ),
         // Str -> Str -> Array Str
         BinaryOp::StringSplit => (
             mk_uniftype::str(),
@@ -569,8 +574,16 @@ pub fn get_nop_type(
                 mk_uniftype::array(element_type),
             )
         }
-        // This should not happen, as MergeContract() is only produced during evaluation.
-        NAryOp::MergeContract => panic!("cannot typecheck MergeContract()"),
+        // Morally: Label -> Record -> Record -> Record
+        // Actual: Dyn -> Dyn -> Dyn -> Dyn
+        NAryOp::MergeContract => (
+            vec![
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic(),
+                mk_uniftype::dynamic(),
+            ],
+            mk_uniftype::dynamic(),
+        ),
         // Morally: Sym -> Polarity -> Lbl -> Lbl
         // Actual: Sym -> Polarity -> Dyn -> Dyn
         NAryOp::LabelInsertTypeVar => (
@@ -584,43 +597,60 @@ pub fn get_nop_type(
     })
 }
 
-/// 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:
+/// The type of a custom contract. In nickel syntax, the returned type is:
 ///
 /// ```nickel
-/// Dyn -> [| 'Ok, 'Proceed, 'Error { message: String, notes: Array String } |]
+/// Dyn -> Dyn -> [|
+///   'Ok Dyn,
+///   'Error { message | String | optional, notes | Array String | optional }
+/// |]
 /// ```
-pub fn immediate_type() -> UnifType {
+pub fn custom_contract_type() -> UnifType {
     mk_uty_arrow!(
         mk_uniftype::dynamic(),
-        mk_uty_enum!(
-            "Ok",
-            "Proceed",
-            (
-                "Error",
-                mk_uty_record!(
-                    ("message", mk_uniftype::str()),
-                    ("notes", mk_uniftype::array(mk_uniftype::str()))
-                )
-            )
-        )
+        mk_uniftype::dynamic(),
+        custom_contract_ret_type()
     )
 }
 
-/// 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()
+/// The return type of a custom contract. See [custom_contract_type].
+///
+/// ```nickel
+/// [|
+///   'Ok Dyn,
+///   'Error { message | String | optional, notes | Array String | optional }
+/// |]
+/// ```
+pub fn custom_contract_ret_type() -> UnifType {
+    use crate::term::make::builder;
+
+    let error_data = builder::Record::new()
+        .field("message")
+        .optional()
+        .contract(TypeF::String)
+        .no_value()
+        .field("notes")
+        .contract(Type {
+            typ: TypeF::Array(Box::new(Type {
+                typ: TypeF::String,
+                pos: TermPos::None,
+            })),
+            pos: TermPos::None,
+        })
+        .optional()
+        .no_value();
+
+    mk_uty_enum!(
+        ("Ok", mk_uniftype::dynamic()),
+        // /!\ IMPORTANT
+        //
+        // During typechecking, `TypeF::Flat` all have been transformed to `UnifType::Contract(..)`
+        // with their associated environment. Generating a `TypeF::Flat` at this point will panic
+        // in the best case (we should catch that), or have incorrect behavior in the worst case.
+        // Do not turn this into `UnifType::concrete(TypeF::Flat(..))`.
+        (
+            "Error",
+            UnifType::Contract(error_data.build(), SimpleTermEnvironment::new())
+        )
     )
 }
diff --git a/core/stdlib/internals.ncl b/core/stdlib/internals.ncl
index ff99d391..255455ce 100644
--- a/core/stdlib/internals.ncl
+++ b/core/stdlib/internals.ncl
@@ -2,124 +2,180 @@
   # Internal operations. Can't be accessed from user code because `$` is not a
   # valid starting character for an identifier.
 
-  # Builtin contract implementations
-  "$dyn" = fun _label value => value,
-
-  "$num" = fun label value => if %typeof% value == 'Number then value else %blame% label,
-
-  "$bool" = fun label value => if %typeof% value == 'Bool then value else %blame% label,
-
-  "$string" = fun label value => if %typeof% value == 'String then value else %blame% label,
-
-  "$foreign_id" = fun label value => if %typeof% value == 'ForeignId then value else %blame% label,
-
-  "$fail" = fun label _value => %blame% label,
+  # Builtin contract implementations. Note that these are wrapped in a
+  # `CustomContract` constructor by the interpreter on the Rust side, so they
+  # can be written without calling to `%contract/custom%`, but they still follow
+  # the same convention (in particular the return type is [| 'Ok _, 'Error {..}
+  # |]` as well.
+
+  # Contract for the `Dyn` type. It's just an always accepting contract.
+  "$dyn" = fun _label value => 'Ok value,
+
+  # Contract for the `Number` type.
+  "$num" = fun _label value =>
+    if %typeof% value == 'Number then
+      'Ok value
+    else
+      'Error {},
 
-  "$array" = fun element_contract label value =>
-    if %typeof% value == 'Array then
-      %contract/array_lazy_apply% (%label/go_array% label) value element_contract
+  # Contract for the `Bool` type.
+  "$bool" = fun _label value =>
+    if %typeof% value == 'Bool then
+      'Ok value
     else
-      %blame% label,
+      'Error {},
 
-  # A specialized version of `$array $dyn`, but which is constant time.
-  "$array_dyn" = fun label value =>
-    if %typeof% value == 'Array then
-      value
+  # Contract for the `String` type.
+  "$string" = fun _label value =>
+    if %typeof% value == 'String then
+      'Ok value
     else
-      %blame% label,
+      'Error {},
 
-  "$func" = fun domain codomain label value =>
-    if %typeof% value == 'Function then
-      (
-        fun x =>
-          %contract/apply%
-            codomain
-            (%label/go_codom% label)
-            (value (%contract/apply% domain (%label/flip_polarity% (%label/go_dom% label)) x))
-      )
+  # Contract for `ForeignId` values.
+  "$foreign_id" = fun _label value =>
+    if %typeof% value == 'ForeignId then
+      'Ok value
     else
-      %blame% label,
+      'Error {},
+
+  # Contract for the `Array T` type, where `element_contract` is the contract
+  # for `T`.
+  "$array" = fun Element =>
+    fun label value =>
+      if %typeof% value == 'Array then
+        'Ok (
+          %contract/array_lazy_apply%
+            (%label/go_array% label)
+            value
+            Element
+        )
+      else
+        'Error { message = "expected an array" },
 
-  # A specialied version of `_ -> Dyn`
-  "$func_dom" = fun domain label value =>
-    if %typeof% value == 'Function then
-      (
-        fun x =>
-          value
-            (
-              %contract/apply%
-                domain
-                (%label/flip_polarity% (%label/go_dom% label))
-                x
-            )
-      )
+  # A specialized version of `$array $dyn`, but which is constant time.
+  "$array_dyn" = fun _label value =>
+    if %typeof% value == 'Array then
+      'Ok value
     else
-      %blame% label,
+      'Error { message = "expected an array" },
+
+  # Contract for the function type `Domain -> Codomain`.
+  "$func" = fun Domain Codomain =>
+    fun label value =>
+      if %typeof% value == 'Function then
+        'Ok (
+          fun x =>
+            %contract/apply%
+              Codomain
+              (%label/go_codom% label)
+              (value (%contract/apply% Domain (%label/flip_polarity% (%label/go_dom% label)) x))
+        )
+      else
+        'Error { message = "expected a function" },
+
+  # A specialized version of `_ -> Dyn`
+  "$func_dom" = fun Domain =>
+    fun label value =>
+      if %typeof% value == 'Function then
+        'Ok (
+          fun x =>
+            value
+              (
+                %contract/apply%
+                  Domain
+                  (%label/flip_polarity% (%label/go_dom% label))
+                  x
+              )
+        )
+      else
+        'Error { message = "expected a function" },
 
   # A specialied version of `Dyn -> _`
-  "$func_codom" = fun codomain label value =>
-    if %typeof% value == 'Function then
-      (
-        fun x =>
-          %contract/apply%
-            codomain
-            (%label/go_codom% label)
-            (value x)
-      )
-    else
-      %blame% label,
+  "$func_codom" = fun Codomain =>
+    fun label value =>
+      if %typeof% value == 'Function then
+        'Ok (
+          fun x =>
+            %contract/apply%
+              Codomain
+              (%label/go_codom% label)
+              (value x)
+        )
+      else
+        'Error { message = "expected a function" },
 
   # A specialied version of `Dyn -> Dyn`
-  "$func_dyn" = fun label value =>
+  "$func_dyn" = fun _label value =>
     if %typeof% value == 'Function then
-      value
+      'Ok value
     else
-      %blame% label,
-
-  "$forall_var" = fun sealing_key label value =>
-    let current_polarity = %label/polarity% label in
-    let polarity = (%label/lookup_type_variable% sealing_key label).polarity in
-    if polarity == current_polarity then
-      %unseal% sealing_key value (%blame% label)
-    else
-      # [^forall_chng_pol]: Blame assignment for polymorphic contracts
-      # should take into account the polarity at the point the forall was
-      # introduced, not the current polarity of the variable occurrence. Indeed,
-      # forall can never blame in a negative position (relative to the
-      # forall): the contract is entirely on the callee.
-      #
-      # Thus, for correct blame assignment, we want to set the polarity to the
-      # forall polarity (here `polarity`). Because we only have the `chng_pol`
-      # primop, and we know that in this branch they are unequal, flipping the
-      # current polarity will indeed give the original forall's polarity.
-      %seal% sealing_key (%label/flip_polarity% label) value,
-
-  "$forall" = fun sealing_key polarity contract label value =>
-    contract (%label/insert_type_variable% sealing_key polarity label) value,
-
-  "$enum" = fun case label value =>
-    if %typeof% value == 'Enum then
-      %contract/apply% case label value
-    else
-      %blame% (%label/with_message% "expected an enum" label),
+      'Error { message = "expected a function" },
+
+  # Contract for a polymorphic variable. This contract is stored in an
+  # environment during contract generation when a forall introduces a variable
+  # and is inserted whenever this variable occurs somewhere in the contract of
+  # the body of the forall.
+  "$forall_var" = fun sealing_key =>
+    fun label value =>
+      let current_polarity = %label/polarity% label in
+      let polarity = (%label/lookup_type_variable% sealing_key label).polarity in
+      if polarity == current_polarity then
+        'Ok (%unseal% sealing_key value (%blame% label))
+      else
+        # [^forall_label_flip_polarity]: Blame assignment for polymorphic
+        # contracts should take into account the polarity at the point the forall
+        # was introduced, not the current polarity of the variable occurrence.
+        # Indeed, forall can never blame in a negative position (relative to the
+        # forall): the contract is entirely on the callee.
+        #
+        # Thus, for correct blame assignment, we want to set the polarity to the
+        # forall polarity (here `polarity`). Because we only have the
+        # `%label/flip_polarity%` primop, and we know that in this branch they are
+        # unequal, flipping the current polarity will indeed give the original
+        # forall's polarity.
+        'Ok (%seal% sealing_key (%label/flip_polarity% label) value),
+
+  # Contract for `forall a. T`, where `contract` is the contract for `T`. This
+  # contract mostly set appropriate metadata in the label and forward the rest
+  # to `T`.
+  "$forall" = fun sealing_key polarity Contract label value =>
+    let label = (%label/insert_type_variable% sealing_key polarity label) in
+    %contract/apply_as_custom% Contract label value,
+
+  # Contract for an enum type `[| 'tag1, 'tag2 arg, ... |]`. A match expression
+  # depending on the structure of the enum type is generated by the interpreter
+  # and provided through `matcher`, and does the bulk of the work. This contract
+  # is just a wrapper that checks that the value is an enum and forwards the rest
+  # to `matcher`.
+  "$enum" = fun matcher =>
+    fun label value =>
+      if %typeof% value == 'Enum then
+        matcher label value
+      else
+        'Error { message = "expected an enum" },
 
-  "$enum_fail" = fun label =>
-    %blame% (%label/with_message% "tag not in the enum type" label),
+  # Error branch used when elaborating the matcher for an enum type. See
+  # `$enum`.
+  "$enum_fail" = fun _value => 'Error { message = "tag not in the enum type" },
 
   # Contract for an enum variant with tag `'tag`, that is any value of the form
   # `'tag exp`.
-  "$enum_variant" = fun tag label value =>
-    if %enum/is_variant% value then
-      let value_tag = %enum/get_tag% value in
+  "$enum_variant" = fun tag =>
+    fun _label value =>
+      if %enum/is_variant% value then
+        let value_tag = %enum/get_tag% value in
 
-      if value_tag == tag then
-        value
+        if value_tag == tag then
+          'Ok value
+        else
+          'Error {
+            message = "expected `'%{%to_string% tag}`, got `'%{%to_string% value_tag}`"
+          }
       else
-        let msg = "expected `'%{%to_string% tag}`, got `'%{%to_string% value_tag}`" in
-        %blame% (%label/with_message% "tag mismatch: %{msg}" label)
-    else
-      %blame% (%label/with_message% "expected an enum variant" label),
+        'Error { message = "expected an enum variant" },
 
+  # Tail wrapper for the tail of an enum type.
   "$forall_enum_tail" = fun label value =>
     # Theoretically, we should seal/unseal values that are part of enum tail
     # and `$forall_enum_tail` should be defined similarly to
@@ -146,151 +202,198 @@
     # While this might be an issue to investigate in the longer term, or for
     # the next major version, we continue to just not enforce parametricity
     # for enum types for now to maintain backward-compatibility.
-    value,
-
-  "$record" = fun field_contracts tail_contract label value =>
-    if %typeof% value == 'Record then
-      # Returns the sub-record of `left` containing only those fields which are not
-      # present in `right`. If `left` has a sealed polymorphic tail then it will be
-      # preserved.
-      let field_diff = fun left right =>
-        std.array.fold_left
-          (
-            fun acc field =>
-              if %record/has_field% field right then
-                acc
-              else
-                %record/insert% field acc (left."%{field}")
-          )
-          (%record/empty_with_tail% left)
-          (%record/fields% left)
-      in
-      let contracts_not_in_value = field_diff field_contracts value in
-      let missing_fields = %record/fields% contracts_not_in_value in
-      if %array/length% missing_fields == 0 then
-        let tail_fields = field_diff value field_contracts in
-        let fields_with_contracts =
-          std.array.fold_left
-            (
-              fun acc field =>
-                if %record/has_field% field field_contracts then
-                  let contract = field_contracts."%{field}" in
-                  let label = %label/go_field% field label in
-                  let val = value."%{field}" in
-                  %record/insert% field acc (%contract/apply% contract label val)
-                else
-                  acc
-            )
-            {}
-            (%record/fields% value)
-        in
-        tail_contract fields_with_contracts label tail_fields
+    'Ok value,
+
+  # Record contract.
+  #
+  # Record contracts are written and manipulated as standard record literals by
+  # the user, which is convenient (they can be written in a natural way and all
+  # record operations apply on them, they can be merged, extended, accessed, etc.)
+  #
+  # Before being applied, they still need to be wrapped to provide a proper
+  # error message when the checked value isn't a record, and to apply the proper
+  # merge operator flavour.
+  #
+  # Note that `%record/contract_merge%` returns an immediate error as `'Error`
+  # in case of extra fields. However, due to the lazy nature of merging and the
+  # existence of field without a definition, missing fields are a delayed error.
+  # This might have a non-obvious behavior when using boolean combinators on
+  # record contracts.
+  "$record_contract" = fun record_contract =>
+    fun label value =>
+      if %typeof% value == 'Record then
+        %record/merge_contract% label value record_contract
       else
-        let plural = if %array/length% missing_fields == 1 then "" else "s" in
-        %blame%
-          (
-            %label/with_message%
-              "missing field%{plural} `%{std.string.join ", " missing_fields}`"
-              label
-          )
-    else
-      %blame% (%label/with_message% "not a record" label),
+        'Error { message = "expected a Record" },
+
+  # Contract for a static record type `{ field1: T1, field2: T2, ... }`.
+  #
+  # The record type is reified by the interpreter as a record value `{ field1 =
+  # <contract of T1>, field2 = <contract of T2>, ... }` and given as the
+  # `field_contracts` argument. Depending on the tail of the record type, the
+  # corresponding `tail_wrapper` is also provided by the interpreter. Finally,
+  # `has_tail` is a boolean indicating if the record type has a non-empty tail,
+  # which makes it possible to push more checks in the immediate part of the
+  # contract.
+  "$record_type" =
+    let plural = fun list => if %array/length% list == 1 then "" else "s" in
 
-  # 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),
+    fun field_contracts tail_wrapper has_tail =>
+      fun label value =>
+        if %typeof% value == 'Record then
+          let split_result = %record/split_pair% field_contracts value in
 
-  # Immediate dictionary contract for `{_ : T}`
-  "$dict_type" = fun contract label value =>
-    if %typeof% value == 'Record then
-      %record/map%
-        value
-        (
-          fun _field field_value =>
-            %contract/apply% contract (%label/go_dict% label) field_value
+          if split_result.left_only != {} then
+            let missing_fields = %record/fields% split_result.left_only in
+
+            'Error {
+              message = "missing field%{plural missing_fields} `%{std.string.join ", " missing_fields}`",
+            }
+          else if split_result.right_only != {} && !has_tail then
+            let extra_fields = %record/fields% split_result.right_only in
+
+            'Error {
+              message = "extra field%{plural extra_fields} `%{std.string.join ", " extra_fields}`",
+            }
+          else
+            let extra_fields = split_result.right_only in
+            let fields_with_contracts =
+              %record/map%
+                split_result.right_center
+                (
+                  fun field value =>
+                    %contract/apply%
+                      field_contracts."%{field}"
+                      (%label/go_field% field label)
+                      value
+                )
+            in
+
+            # Note that the tail_wrapper has the same type as a custom contract
+            # and thus properly wraps the result in `'Ok`, so we can call it
+            # directly
+            tail_wrapper extra_fields label fields_with_contracts
+        else
+          'Error { message = "expected a record" },
+
+  # Dictionary contract for `{_ | T}`. The contracts are mapped onto fields'
+  # metadata.
+  "$dict_contract" = fun Contract =>
+    fun label value =>
+      if %typeof% value == 'Record then
+        'Ok (
+          %contract/record_lazy_apply%
+            (%label/go_dict% label)
+            value
+            (fun _field => Contract)
         )
-    else
-      %blame% (%label/with_message% "not a record" label),
+      else
+        'Error { message = "not a record" },
+
+  # Dictionary contract for `{_ : T}`. The contracts are mapped directly onto
+  # the fields' values, which makes it more eager (in some specific sense - this
+  # contract is still lazy/delayed) than `$dict_contract`.
+  "$dict_type" = fun Contract =>
+    fun label value =>
+      if %typeof% value == 'Record then
+        'Ok (
+          %record/map%
+            value
+            (
+              fun _field field_value =>
+                %contract/apply% Contract (%label/go_dict% label) field_value
+            )
+        )
+      else
+        'Error { message = "not a record" },
 
   # A specialized version of either `{_ | Dyn}` or `{_ : Dyn}` (which are
   # equivalent), but which is constant time.
   "$dict_dyn" = fun label value =>
     if %typeof% value == 'Record then
-      value
+      'Ok value
     else
-      %blame% (%label/with_message% "not a record" label),
-
-  "$forall_record_tail" = fun sealing_key constr acc label value =>
+      'Error { message = "not a record" },
+
+  # Tail wrapper for a polymorphic tail. Wrappers are used in the implementation
+  # of the contract for static record types, and have the same return type as
+  # other contract implementations.
+  #
+  # The `sealing_key` is the sealing key corresponding to the variable occuring
+  # in the tail. `constr` are the fields that can't appear in the value because
+  # they are already in the tail. Both are provided by the interpreter when
+  # applying this function.
+  #
+  # `extra_fields` is provided by the caller `$record`, and is the set of fields
+  # that are weren't part of the record type (thus are candidates for being in
+  # the tail).
+  "$forall_record_tail" = fun sealing_key constr extra_fields label value =>
     let current_polarity = %label/polarity% label in
     let polarity = (%label/lookup_type_variable% sealing_key label).polarity in
     let plural = fun list => if %array/length% list == 1 then "" else "s" in
+
+    # If the polarity of the forall is the same as the current polarity, we are
+    # in a positive occurrence (relative to the forall). Think of a return value
+    # of a function type. In this case, the tail is supposed to be sealed, and
+    # the function isn't allowed to inject any extra field in that sealed tail.
     if polarity == current_polarity then
-      if value == {} then
+      if extra_fields == {} then
         let tagged_label = %label/with_message% "polymorphic tail mismatch" label in
-        let tail = %record/unseal_tail% sealing_key tagged_label value in
-        acc & tail
+        let tail = %record/unseal_tail% sealing_key tagged_label extra_fields in
+
+        'Ok (%record/disjoint_merge% value tail)
       else
-        let extra_fields = %record/fields% value in
-        %blame%
-          (
-            %label/with_message%
-              "extra field%{plural extra_fields} `%{std.string.join ", " extra_fields}`"
-              label
-          )
+        let extra_fields = %record/fields% extra_fields in
+
+        'Error {
+          message = "extra field%{plural extra_fields} `%{std.string.join ", " extra_fields}`",
+        }
+      # Otherwise, we are in a negative occurrence (relative to the forall). Think
+      # of the argument of a function. There, extra fields are allowed, but must
+      # be sealed in the tail to prevent the function from touching them.
     else
+      # Conflicts happen because a polymorphic record tail might have additional
+      # constraints as to what can actually be in there. See the documentation
+      # of the typechecker for more information.
       let conflicts =
         std.array.filter
           (fun field => std.array.elem field constr)
-          (%record/fields% value)
+          (%record/fields% extra_fields)
       in
+
       if conflicts != [] then
-        %blame%
-          (
-            %label/with_message%
-              "field%{plural conflicts} not allowed in tail: `%{std.string.join ", " conflicts}`"
-              label
-          )
+        'Error {
+          message = "field%{plural conflicts} not allowed in tail: `%{std.string.join ", " conflicts}`",
+        }
       else
-        # See [^forall_chng_pol]
-        %record/seal_tail% sealing_key (%label/flip_polarity% label) acc value,
-
-  "$dyn_tail" = fun acc label value => acc & value,
-
-  "$empty_tail" = fun acc label value =>
-    if value == {} then
-      acc
-    else
-      let extra_fields = %record/fields% value in
-      let plural = if %array/length% extra_fields == 1 then "" else "s" in
-      %blame%
-        (
-          %label/with_message%
-            "extra field%{plural} `%{std.string.join ", " extra_fields}`"
-            label
+        # See [^forall_label_flip_polarity]
+        'Ok (
+          %record/seal_tail%
+            sealing_key
+            (%label/flip_polarity% label)
+            value
+            extra_fields
         ),
 
-  # Take a contract as a predicate, built using %contract/from_predicate%, and
-  # turn it into a generic custom contract (a partial identity Label -> Dyn ->
-  # Dyn) that can be passed to %contract/apply%.
-  "$predicate_to_ctr" = fun predicate label value =>
-    if predicate value then
-      value
-    else
-      %blame% label,
+  # Tail wrapper for a dynamic tail. A dynamic tail doesn't seal anything, so we
+  # just add the extra fields back to the returned value. See
+  # `$record_type/delayed`.
+  "$dyn_tail" = fun extra_fields label value => 'Ok (%record/disjoint_merge% value extra_fields),
 
-  # Take a contract as a validator, built using %contract/from_validator%, and
-  # turn it into a generic custom contract (a partial identity Label -> Dyn ->
-  # Dyn) that can be passed to %contract/apply%.
-  "$validator_to_ctr" = fun validator label value =>
-    validator value
+  # Tail wrapper of a helper for an empty tail. At this point, we've already
+  # checked (in the main contract implementation for record types) that there
+  # are no extra fields, so we can just ignore them and return the value as is.
+  "$empty_tail" = fun _extra_fields _label value => 'Ok value,
+
+  # Take a a partial identity Label -> Dyn -> Dyn that can then be passed
+  # applied as a normal funtion in the implementation of `contract/apply`.
+  "$prepare_custom_contract" = fun custom_contract label value =>
+    custom_contract label value
     |> match {
-      'Ok => value,
+      'Ok value => value,
       'Error { message ? null, notes ? [], .. } =>
         let label =
-          if message != null && std.is_string message then
+          if std.is_string message then
             %label/with_message% message label
           else
             label
@@ -304,88 +407,35 @@
         in
 
         %blame% label,
-      # The contract of `std.contract.from_validator` should guarantee that we
+      # The contract of `std.contract.custom` should guarantee that we
       # never reach this case. However, nothing prevents user from using
-      # `%contract/from_validator%` directly, so we still try to handle a
-      # misbehaving validator gracefully.
-      _ =>
+      # `%contract/custom%` directly, so we still try to handle a misbehaving
+      # custom contract gracefully.
+      value =>
         %blame%
           (
             %label/with_notes%
               (
                 %force%
-                  [
-                    "The validator for this contract returned an invalid result (which must be either `'Ok` or `'Error {message, ..}`)",
-                    "Please check the implementation of the contract that has been broken"
-                  ]
+                  (
+                    %trace%
+                      value
+                      [
+                        "The implementation of this custom contract returned an invalid result (which must be of type `[| 'Ok Dyn, 'Error {..} |]`)",
+                        "Please check the implementation of the contract that has been broken"
+                      ]
+                  )
               )
               label
           )
     },
 
-  # 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,
+  # Dual of prepare_custom_contract. Turns a naked function of a label and a
+  # value to a custom contract-like representation (that is, a function
+  # returning an enum - however the result is still a naked function, which must
+  # be applicable as a normal function, and isn't wrapped in
+  # `%contract/custom%`).
+  "$naked_to_custom" = fun naked label value => 'Ok (naked label value),
 
   # Recursive priorities operators
 
diff --git a/core/stdlib/std.ncl b/core/stdlib/std.ncl
index 33000c64..4f3d22ec 100644
--- a/core/stdlib/std.ncl
+++ b/core/stdlib/std.ncl
@@ -62,16 +62,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Array then
                 if %array/length% value != 0 then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "empty array" }
               else
                 'Error { message = "not a array" }
-          )
-          null,
+          ),
 
     first
       : forall a. Array a -> a
@@ -502,6 +501,34 @@
         "%
       = fun f n => %array/generate% n f,
 
+    compare
+      : forall a. (a -> a -> [| 'Lesser, 'Equal, 'Greater |]) -> Array a -> Array a -> [| 'Lesser, 'Equal, 'Greater |]
+      | doc m%"
+          `compare order a b` lexicographically compares `a` and `b`, using `order` to
+          compare the individual elements.
+
+          # Examples
+
+          ```nickel
+          std.array.compare std.number.compare [3, 1, 4] [6, 2, 8] =>
+            'Lesser
+          std.array.compare std.string.compare ["hello", "world"] ["hello"] =>
+            'Greater
+          ```
+        "%
+      =
+        let refine = fun left right =>
+          left
+          |> match {
+            'Equal => right,
+            _ => left,
+          }
+        in
+        fun order a b =>
+          zip_with order a b
+          @ [std.number.compare (length a) (length b)]
+          |> fold_right refine 'Equal,
+
     sort
       : forall a. (a -> a -> [| 'Lesser, 'Equal, 'Greater |]) -> Array a -> Array a
       | doc m%"
@@ -982,55 +1009,31 @@
       = fun message label => %blame% (%label/with_message% message label),
 
     custom
-      | (
-        Dyn -> [|
-          'Ok,
-          'Proceed,
-          'Error { message | String | optional, notes | Array String | optional }
+      : (
+        Dyn -> Dyn -> [|
+          'Ok Dyn,
+          'Error {
+            message | String | optional,
+            notes | Array String | optional
+          }
         |]
-      ) -> (Dyn -> Dyn -> Dyn) -> Dyn
+      ) -> Dyn
       | doc m%"
-          Build a generic custom contract from an immediate part and a delayed
-          part.
-
-          # Immediate vs delayed
-
-          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 delayed contracts (such as
-          contracts operating on functions or data structures like records and
-          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. They consist of an immediate part
-          and a delayed part.
+          Build a generic custom contract. Whenever possible, more restricted
+          constructors such as `std.contract.from_predicate` or
+          `std.contract.from_validator` should be used. However, the most
+          general `std.contract.custom` might be required to write delayed
+          contracts or contracts taking other contracts as parameters.
 
-          ## Immediate part
+          # Custom contracts
 
-          The immediate part is a function that takes the checked value as a
-          parameter and returns either:
+          The argument of `std.contract.custom` is a function that takes a label
+          and the value to be checked, and returns `'Ok value` to return
+          the original value with potential delayed checks inside, or `'Error
+          {..}` to signal an immediate contract violation.
 
-          - `'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.
+          The label should only be used for delayed checks. The preferred method
+          to indicate a contract violation is to return `'Error {..}`.
 
           For more details on custom contracts and delayed contracts, please
           refer to the corresponding section of the user manual.
@@ -1040,49 +1043,30 @@
           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 the delayed part as `(Label -> Dyn ->
-          Dyn)`, and the return type of `custom` as `Contract`.
+          [| ... |])`, and the return type of `custom` as `Contract`.
 
           # Examples
 
           ```nickel
           let Nullable = fun Contract =>
-            std.contract.custom
-              (fun value =>
-                if value == null then
-                  'Ok
-                else
-                  'Proceed
-              )
-              (std.contract.apply Contract)
+            std.contract.custom (fun label value =>
+              if value == null then
+                'Ok value
+              else
+                std.contract.apply_as_custom Contract label value
+            )
           in
+
           null | Nullable Number
             => null
           ```
         "%
-      = 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,
+      = fun contract => %contract/custom% contract,
 
     from_predicate
       | (Dyn -> Bool) -> Dyn
       | doc m%"
-          Build an immediate custom contract from a boolean predicate.
+          Build a contract from a boolean predicate.
 
           # Contract application
 
@@ -1091,10 +1075,11 @@
           apply it as a normal function, as in
           `(std.contract.from_predicate my_pred) label value`. While possible,
           this wasn't recommended nor really officially supported: all contracts
-          should be applied using a contract annotation or `std.contract.apply`,
-          as explained in the documentation of `std.contract.apply`.
+          should be applied using a contract annotation or `std.contract.apply`
+          and its variants, as explained in the documentation of
+          `std.contract.apply`.
 
-          From Nickel 1.8 and ownwards, `from_predicate` generates a bespoke
+          From Nickel 1.8 and onwards, `from_predicate` generates a bespoke
           contract value that can only be used in an annotation, be passed to
           `std.contract.apply` or other operations on contracts, but can't be
           used as normal function anymore.
@@ -1116,23 +1101,29 @@
       = fun pred =>
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if pred value then
-                'Ok
+                'Ok value
               else
                 'Error {}
-          )
-          null,
+          ),
 
     from_validator
       | (
         Dyn -> [|
           'Ok,
-          'Error { message | String | optional, notes | Array String | optional }
+          'Error {
+            message
+              | String
+              | optional,
+            notes
+              | Array String
+              | optional,
+          }
         |]
       ) -> Dyn
       | doc m%%"
-          Build an immediate custom contract from a validator.
+          Build a contract from a validator.
 
           # Validator
 
@@ -1165,10 +1156,23 @@
               'Error { message = "expected a number, got a %{vtype}" }
           })
           in
+
           0 | IsZero
           ```
         "%%
-      = fun validator => %contract/custom% validator null,
+      # A validator turns out to be a valid custom contract as well (which just
+      # never returns a value with delayed checks)
+      = fun validator =>
+        %contract/custom%
+          (
+            fun label value =>
+              let result = validator value in
+
+              if result == 'Ok then
+                'Ok value
+              else
+                result
+          ),
 
     Sequence
       | doc m%"
@@ -1203,11 +1207,25 @@
           x | C
           ```
         "%
-      = fun contracts label value =>
-        std.array.fold_left
-          (fun acc contract => std.contract.apply contract label acc)
-          value
-          contracts,
+      = fun contracts =>
+        %contract/custom%
+          (
+            fun label value =>
+              std.array.fold_left
+                (
+                  fun acc Contract =>
+                    acc
+                    |> match {
+                      'Ok value =>
+                        std.contract.apply_as_custom Contract label value,
+                      # if we encountered an error at some point in the pipeline, we
+                      # just forward it from this point
+                      error => error
+                    }
+                )
+                ('Ok value)
+                contracts
+          ),
 
     label
       | doc m%"
@@ -1221,6 +1239,10 @@
           manipulated by the functions in this module. Labels thus offer a way to
           customize the error message that will be shown if a contract is broken.
 
+          Note that the label should only be used for delayed checks. Immediate
+          error reporting is better done in custom contracts by returning an
+          error value.
+
           The setters (`with_XXX` functions) always operate on the current error
           diagnostic, which is the last diagnotic on the stack. If the stack is
           empty, a fresh diagnostic is silently created when using a setter.
@@ -1229,12 +1251,13 @@
           can't be modified anymore. All diagnostics will be shown during error
           reporting, with the most recent being used as the main one.
 
-          `std.contract.apply` is the operation that pushes a new fresh diagnostic on
-          the stack, saving the previously current error diagnostic. The function
-          `std.contract.apply` is typically used to apply subcontracts inside a parent
-          contract. Stacking the current diagnostic potentially customized by
-          the parent contract saves the information inside, and provides a fresh
-          diagnostic for the child contract to use.
+          `std.contract.apply` and `std.contract.apply_as_custom` are the
+          operations that push a new fresh diagnostic on the stack, saving the
+          previously current error diagnostic. `apply` and friends are typically
+          used to apply subcontracts inside a parent contract. Stacking the
+          current diagnostic potentially customized by the parent contract saves
+          the information inside, and provides a fresh diagnostic for the child
+          contract to use.
         "%
       = {
         with_message
@@ -1257,28 +1280,32 @@
 
               # Examples
 
-              ```nickel
-              let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
+              This example implements a contract which ensures that the value is
+              a record with a field `foo` that is an even number.
 
-              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
-                      "num subcontract failed! (out of bound)"
-                      label
-                    )
-                    value
-                )
+              ```nickel
+              let FooIsEven = std.contract.custom (fun label =>
+                match {
+                  record @ { foo, .. } =>
+                    'Ok (
+                      std.record.map (fun key value =>
+                        if key == "foo"
+                        && !(std.is_number value && value % 2 == 0) then
+                          label
+                          |> std.contract.label.with_message "field foo must be an even number"
+                          |> std.contract.blame
+                        else
+                          value
+                      )
+                      record
+                    ),
+                  _ => 'Error {},
+                }
+              )
               in
 
-              5 | Contract
+              { foo = 4, hello = "world" } | FooIsEven
+                => { foo = 4, hello = "world" }
               ```
             "%
           = fun message label => %label/with_message% message label,
@@ -1303,31 +1330,22 @@
 
               # Examples
 
-              ```nickel
-              let ContractNum = std.contract.from_predicate (fun x => x > 0 && x < 50) in
+              This example is illustrative. In practice, returning `'Error {..}`
+              should always be preferred over manipulating the label directly,
+              at least whenever possible.
 
-              let Contract = std.contract.custom
-                (fun value =>
-                  if std.is_number value then
-                    'Proceed
-                  else
-                    'Ok
-                )
-                (fun label value =>
-                  std.contract.apply
-                    ContractNum
-                    (label
-                     |> std.contract.label.with_message "num subcontract failed! (out of bound)"
-                     |> std.contract.label.with_notes [
-                          "The value was a number, but this number is outside the expected bounds",
-                          "The value must be a number between 0 and 50, both excluded",
-                        ]
-                    )
-                    value
-                )
+              ```nickel
+              let AlwaysFailWithNotes = std.contract.custom (fun label _ =>
+                label
+                |> std.contract.label.with_notes [
+                  "This is note 1",
+                  "This is note 2",
+                ]
+                |> std.contract.blame
+              )
               in
 
-              5 | Contract
+              null | AlwaysFailWithNotes
               ```
             "%
           # the %label/with_notes% operator expects an array of strings which is
@@ -1343,65 +1361,82 @@
 
               # Examples
 
+              This example is illustrative. In practice, returning `'Error {..}`
+              should always be preferred over manipulating the label directly
+              whenever possible.
+
               ```nickel
-              let AlwaysFailWithNotes = std.contract.delayed (fun label _ =>
+              let AlwaysFailWithNotes = std.contract.custom (fun label _ =>
                 label
                 |> std.contract.label.append_note "This is note 1"
                 |> std.contract.label.append_note "This is note 2"
                 |> std.contract.blame
               )
               in
+
               null | AlwaysFailWithNotes
+              ```
             "%
           = fun note label => %label/append_note% note label,
       },
 
     apply
       | doc m%"
-          Applies a contract to a label and a value.
+          Applies a contract to a label and a value. Either aborts the execution
+          with a broken contract error if the contract fails, or proceeds with
+          the evaluation of the value.
+
+          A contract annotation such as `value | Contract` is eventually
+          translated (automatically) to a call to `std.contract.apply Contract label
+          value` where `label` is inserted by the Nickel interpreter.
 
           Type: `Contract -> Label -> Dyn -> Dyn`
           (for technical reasons, this function isn't actually statically typed)
 
-          Nickel supports user-defined contracts defined as predicates, as
-          general functions, as records, etc. The interpreter also performs additional
-          bookkeeping for error reporting when applying a contract in an
-          expression `value | Contract`. You should thus not use standard
-          function application to apply a contract, even when defined as a
-          function, but use `std.contract.apply` instead.
+          # Arguments
+
+          `apply` takes three arguments. The arguments are in order:
 
-          # Argument
+          - a contract which is currently either a custom contract (produced by
+            the stdlib constructors `std.contract.custom`,
+            `std.contract.from_validator` or `std.contract.from_predicate`), a
+            record, a static type or a naked function (see legacy contracts
+            below).
+          - a label (see `std.contract.label`)
+          - the value to be checked
 
-          The argument must be a contract, which is currently either a custom
-          contract or a record.
+          # Return value
 
-          ## Custom contracts
+          `apply` returns the original value upon success, with potential
+          delayed checks inserted inside. Some contracts even return a modified
+          value, such as record contract which can set default values.
 
-          When applying a custom contract, the argument must have been produced
-          by one of the stdlib function for creating custom contracts, such as
-          `std.contract.from_predicate` or `std.contract.custom`.
+          # Legacy contracts
 
           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` or `std.contract.delayed` instead.
+          `std.contract.custom` instead (the return type must be adapted).
 
           # Examples
 
+          This example is a custom re-implementation of the builtin contract
+          `[| 'Foo Number |]`. This makes for a short illustration of `apply`,
+          but you should of course just use the builtin contract in real-world
+          applications.
+
           ```nickel
-          let Nullable = fun Contract =>
+          let FooOfNumber = fun Contract =>
             std.contract.custom
-              (fun value =>
-                if value == null then
-                  'Ok
-                else
-                  'Proceed
+              (fun label =>
+                match {
+                  'Foo arg => 'Foo (std.contract.apply Contract label value),
+                  _ => 'Error {},
+                }
               )
-              (std.contract.apply Contract)
           in
 
-          let Contract = Nullable {foo | Number} in
-          { foo = 1 } | Contract
+          { foo = 1 } | Nullable {foo | Number}
           ```
 
           # Diagnostics stack
@@ -1415,21 +1450,19 @@
           ## Illustration
 
           ```nickel
-          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"
-            |> std.contract.blame
+          let ChildContract = std.contract.from_validator (fun _ =>
+            'Error {
+              message = "child's message",
+              notes = ["child's note"]
+            }
           )
           in
 
-          let ParentContract = std.contract.delayed (fun label value =>
-            let label =
-              label
-              |> std.contract.label.with_message "parent's message"
-              |> std.contract.label.append_note "parent's note"
-            in
-            std.contract.apply ChildContract label value
+          let ParentContract = std.contract.from_validator (fun _ =>
+            'Error {
+              message = "parent's message",
+              notes = ["parent's note"]
+            }
           )
           in
 
@@ -1440,22 +1473,46 @@
           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:
+          This behavior is also observed when manipulating the label directly
+          and using `std.contract.blame` instead of returning an error value.
+          ```
+        "%
+      = fun contract label value =>
+        %contract/apply% contract (%label/push_diag% label) value,
+
+    apply_as_custom
+      | doc m%"
+          Variant of `std.contract.apply` which returns `'Error {..}` upon
+          immediate failure of the contract instead of aborting the execution
+          with blame error, and wraps the return value of `std.contract.apply as
+          `'Ok value` otherwise.
+
+          Beside the return value and error handling, `apply_as_custom` has the
+          same behavior as `std.contract.apply`. See `std.contract.apply` for
+          more details.
+
+          `apply_as_custom` is intended to be used when calling a contract from
+          a custom contract, since the return type of `apply_as_custom` is
+          precisely the same the return type of a custom contract.
+
+          # Examples
 
           ```nickel
-          let ChildContract = std.contract.from_validator (fun _ =>
-            'Error {
-              message = "child's message",
-              notes = ["child's note"]
-            }
-          ) in
-          # etc.
+          let Nullable = fun Contract =>
+            std.contract.custom
+              (fun label value =>
+                if value == null then
+                  'Ok value
+                else
+                  std.contract.apply_as_custom Contract label value
+              )
+          in
+
+          { foo = 1 } | Nullable {foo | Number}
           ```
         "%
       = fun contract label value =>
-        %contract/apply% contract (%label/push_diag% label) value,
+        %contract/apply_as_custom% contract (%label/push_diag% label) value,
 
     unstable
       | doc m%"
@@ -1488,13 +1545,15 @@
             "%
           = fun Domain Codomain =>
             %contract/custom%
-              null
               (
                 fun label function =>
-                  fun arg =>
-                    let arg_with_ctr = std.contract.apply Domain label arg in
-                    function arg_with_ctr
-                    |> std.contract.apply (Codomain arg_with_ctr) label
+                  'Ok (
+                    fun arg =>
+                      let arg_with_ctr = std.contract.apply Domain label arg in
+
+                      function arg_with_ctr
+                      |> std.contract.apply (Codomain arg_with_ctr) label
+                  )
               ),
 
         RangeStep
@@ -1511,16 +1570,15 @@
           =
             %contract/custom%
               (
-                fun value =>
+                fun _label value =>
                   if %typeof% value == 'Number && value < 0 then
                     'Error {
                       message = "invalid range step",
                       notes = ["Expected a positive number, got %{%to_string% value}"],
                     }
                   else
-                    'Ok
-              )
-              null,
+                    'Ok value
+              ),
 
         RangeFun
           | doc m%"
@@ -1537,36 +1595,37 @@
               example, if the first argument is a string, this contract silently
               passes.
             "%
-          =
-            # capture the `contract.label` module to avoid name collisions with
-            # local contract argument `label`
-            let label_module = label in
-            let attach_message = label_module.with_message "invalid range" in
-
-            fun Codomain =>
-              let RangeSecond = fun start =>
-                %contract/custom%
-                  null
-                  (
-                    fun label value =>
-                      if %typeof% start == 'Number && %typeof% value == 'Number then
-                        if start > value then
-                          let start_as_str = %to_string% start in
-                          let end_as_str = %to_string% value in
-                          label
-                          |> attach_message
-                          |> label_module.append_note "Expected a range end greater than %{start_as_str} (range start), got %{end_as_str}"
-                          |> blame
-                        else
-                          value
-                      else
-                        value
-                  )
-              in
+          = fun Codomain =>
+            let RangeSecond = fun start =>
+              %contract/custom%
+                (
+                  fun _label value =>
+                    if %typeof% start == 'Number
+                    && %typeof% value == 'Number
+                    && start > value then
+                      let start_as_str = %to_string% start in
+                      let end_as_str = %to_string% value in
+
+                      'Error {
+                        message = "invalid range",
+                        notes = [
+                          "Expected a range end greater than %{start_as_str} (range start), got %{end_as_str}"
+                        ]
+                      }
+                    else
+                      'Ok value
+                )
+            in
 
-              DependentFun
-                Dyn
-                (fun start => RangeSecond start -> Codomain),
+            fun label value =>
+              %contract/apply%
+                (
+                  DependentFun
+                    Dyn
+                    (fun start => RangeSecond start -> Codomain)
+                )
+                label
+                value,
 
         IndexedArrayFun
           | [| 'Index, 'Split |] -> Dyn
@@ -1593,7 +1652,6 @@
 
             let ArrayIndexFirst =
               %contract/custom%
-                null
                 (
                   fun label value =>
                     if %typeof% value == 'Number then
@@ -1602,19 +1660,25 @@
                         |> attach_message
                         |> label_module.append_note "Expected array index to be a positive integer, got %{%to_string% value} "
                       in
-                      std.contract.apply std.number.Nat label value
+                      std.contract.apply_as_custom std.number.Nat label value
                     else
-                      value
+                      'Ok value
                 )
             in
 
             let ArrayIndexSecond = fun type min_size =>
               %contract/custom%
-                null
                 (
-                  fun label value =>
+                  fun _label value =>
                     if %typeof% min_size == 'Number && %typeof% value == 'Array then
-                      let max_idx = type |> match { 'Index => %array/length% value - 1, 'Split => %array/length% value } in
+                      let max_idx =
+                        type
+                        |> match {
+                          'Index => %array/length% value - 1,
+                          'Split => %array/length% value
+                        }
+                      in
+
                       if min_size > max_idx then
                         let index_as_str = %to_string% min_size in
                         let max_as_str = %to_string% max_idx in
@@ -1624,24 +1688,22 @@
                           else
                             "Expected an array index between 0 and %{max_as_str} (included), got %{index_as_str}"
                         in
-                        label
-                        |> attach_message
-                        |> label_module.append_note note
-                        |> blame
+
+                        'Error {
+                          message = "invalid array indexing",
+                          notes = [note],
+                        }
                       else
-                        value
+                        'Ok value
                     else
-                      value
+                      'Ok value
                 )
             in
 
-            let contract = fun type =>
+            fun type =>
               DependentFun
                 ArrayIndexFirst
-                (fun index => ArrayIndexSecond type index -> Dyn)
-            in
-
-            contract,
+                (fun index => ArrayIndexSecond type index -> Dyn),
 
         ArraySliceFun
           | doc m%"
@@ -1667,7 +1729,6 @@
 
             let SliceIndexFirst =
               %contract/custom%
-                null
                 (
                   fun label value =>
                     if %typeof% value == 'Number then
@@ -1676,54 +1737,57 @@
                         |> attach_message
                         |> label_module.append_note "Expected the array slice start index to be a positive integer, got %{%to_string% value}"
                       in
-                      std.contract.apply std.number.Nat label value
+                      std.contract.apply_as_custom std.number.Nat label value
                     else
-                      value
+                      'Ok value
                 )
             in
 
             let SliceIndexSecond = fun start =>
               %contract/custom%
-                null
                 (
                   fun label value =>
                     if %typeof% start == 'Number && %typeof% value == 'Number then
                       if value < start then
-                        label
-                        |> attach_message
-                        |> label_module.append_note "Expected the array slice indices to satisfy `start <= end`, but got %{%to_string% start} (start) and %{%to_string% value} (end)"
-                        |> blame
+                        'Error {
+                          message = "invalid array slice indexing",
+                          notes = [
+                            "Expected the array slice indices to satisfy `start <= end`, but got %{%to_string% start} (start) and %{%to_string% value} (end)"
+                          ],
+                        }
                       else
                         let label =
                           label
                           |> attach_message
                           |> label_module.append_note "Expected the array slice end index to be a positive integer, got %{%to_string% value}"
                         in
-                        %contract/apply% std.number.Nat label value
+                        std.contract.apply_as_custom std.number.Nat label value
                     else
-                      value
+                      'Ok value
                 )
             in
 
             let ArraySliceArray = fun end_index =>
               %contract/custom%
-                null
                 (
-                  fun label value =>
-                    if %typeof% end_index == 'Number && %typeof% value == 'Array then
-                      if end_index > %array/length% value then
-                        let index_as_str = %to_string% end_index in
-                        let size_as_str = %to_string% (%array/length% value) in
-                        label
-                        |> attach_message
-                        |> label_module.append_note "Expected the slice end index to be between 0 and %{size_as_str} (array's length), got %{index_as_str}"
-                        |> blame
-                      else
-                        value
+                  fun _label value =>
+                    if %typeof% end_index == 'Number
+                    && %typeof% value == 'Array
+                    && end_index > %array/length% value then
+                      let index_as_str = %to_string% end_index in
+                      let size_as_str = %to_string% (%array/length% value) in
+
+                      'Error {
+                        message = "invalid array slice indexing",
+                        notes = [
+                          "Expected the slice end index to be between 0 and %{size_as_str} (array's length), got %{index_as_str}"
+                        ],
+                      }
                     else
-                      value
+                      'Ok value
                 )
             in
+
             DependentFun
               SliceIndexFirst
               (
@@ -1762,34 +1826,28 @@
               In this example, the first call to `f` won't blame, but the second
               will, as `baz` isn't a field of the record argument.
             "%%
-          =
-            # capture the `contract.label` module to avoid name collisions with
-            # local contract argument `label`
-            let label_module = label in
-            let attach_message = label_module.with_message "missing field" in
-
-            fun Codomain =>
-              let HasFieldSecond = fun field =>
-                %contract/custom%
-                  null
-                  (
-                    fun label value =>
-                      if %typeof% field == 'String
-                      && %typeof% value == 'Record
-                      && !(%record/has_field% field value) then
-                        label
-                        |> attach_message
-                        |> label_module.append_note
+          = fun Codomain =>
+            let HasFieldSecond = fun field =>
+              %contract/custom%
+                (
+                  fun _label value =>
+                    if %typeof% field == 'String
+                    && %typeof% value == 'Record
+                    && !(%record/has_field% field value) then
+                      'Error {
+                        message = "missing field",
+                        notes = [
                           "The record given as an argument lacks the required field `%{field}`."
-                        |> blame
-                      else
-                        value
-                  )
-              in
+                        ]
+                      }
+                    else
+                      'Ok value
+                )
+            in
 
-              DependentFun
-                Dyn
-                (fun field => HasFieldSecond field -> Codomain),
+            DependentFun
+              Dyn
+              (fun field => HasFieldSecond field -> Codomain),
       }
   },
 
@@ -1827,8 +1885,13 @@
         "%
       =
         %contract/custom%
-          (fun value => if std.is_enum value then 'Ok else 'Error {})
-          null,
+          (
+            fun _label value =>
+              if std.is_enum value then
+                'Ok value
+              else
+                'Error {}
+          ),
 
     TagOrString
       | doc m%%"
@@ -1869,23 +1932,16 @@
       "%%
       =
         %contract/custom%
-          null
           (
-            fun label value =>
-              let label =
-                std.contract.label.with_message
-                  "expected either a string or an enum tag"
-                  label
-              in
+            fun _label value =>
               %typeof% value
               |> match {
-                'String => %enum/from_string% value,
-                'Enum =>
-                  if is_enum_variant value then
-                    std.contract.blame label
-                  else
-                    value,
-                _ => std.contract.blame label,
+                'String => 'Ok (%enum/from_string% value),
+                'Enum if !(is_enum_variant value) => 'Ok value,
+                _ =>
+                  'Error {
+                    message = "expected either a string or an enum tag"
+                  },
               }
           ),
 
@@ -2136,16 +2192,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value % 1 == 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "not an integer" }
+                  'Error { message = "expected an integer" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     Nat
       | doc m%"
@@ -2165,16 +2220,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value % 1 == 0 && value >= 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "not a natural" }
+                  'Error { message = "expected a natural" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     PosNat
       | doc m%"
@@ -2194,16 +2248,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value % 1 == 0 && value > 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "not positive integer" }
+                  'Error { message = "expected a positive integer" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     NonZero
       | doc m%"
@@ -2221,16 +2274,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'Number then
                 if value != 0 then
-                  'Ok
+                  'Ok value
                 else
-                  'Error { message = "is zero" }
+                  'Error { message = "value is zero" }
               else
-                'Error { message = "not a number" }
-          )
-          null,
+                'Error { message = "expected a number" }
+          ),
 
     is_integer
       : Number -> Bool
@@ -2248,6 +2300,25 @@
       "%
       = fun x => x % 1 == 0,
 
+    compare
+      : Number -> Number -> [| 'Lesser, 'Equal, 'Greater |]
+      | doc m%"
+        Compares two numbers.
+
+        # Examples
+        ```nickel
+        std.number.compare 1 2 =>
+          'Lesser
+        ```
+      "%
+      = fun x y =>
+        if x < y then
+          'Lesser
+        else if x > y then
+          'Greater
+        else
+          'Equal,
+
     min
       : Number -> Number -> Number
       | doc m%"
@@ -2834,6 +2905,23 @@
         |> std.array.filter (fun { field, value } => f field value)
         |> from_array,
 
+    apply_on
+      : forall a b. String -> (a -> a -> b) -> { _ : a } -> { _ : a } -> b
+      | doc m%"
+        `apply_on field f a b` is equivalent to `f a."${field}" b."${field}"`
+        Useful for comparisons.
+
+        # Examples
+
+        ```nickel
+        std.record.apply_on "name" std.string.compare { name = "Alice", age = 27 } { name = "Bob", age = 23 } =>
+          'Lesser
+        std.record.apply_on "age" std.number.compare { name = "Alice", age = 27 } { name = "Bob", age = 23 } =>
+          'Greater
+        ```
+        "%
+      = fun field f a b => f a."%{field}" b."%{field}",
+
     length
       : forall a. { _ : a } -> Number
       | doc m%"
@@ -2898,16 +2986,15 @@
 
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'String then
                 if is_num_literal value then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "invalid number literal" }
               else
-                'Error { message = "not a string" }
-          )
-          null,
+                'Error { message = "expected a string" }
+          ),
 
     Character
       | doc m%"
@@ -2929,16 +3016,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'String then
                 if length value == 1 then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "length different than one" }
               else
-                'Error { message = "not a string" }
-          )
-          null,
+                'Error { message = "expected a string" }
+          ),
 
     Stringable
       | doc m%"
@@ -2969,24 +3055,20 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               let type = std.typeof value in
-              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
+              if 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 then
+                'Ok value
               else
                 'Error {}
-          )
-          null,
+          ),
 
     NonEmpty
       | doc m%"
@@ -3006,16 +3088,15 @@
       =
         %contract/custom%
           (
-            fun value =>
+            fun _label value =>
               if %typeof% value == 'String then
                 if %string/length% value > 0 then
-                  'Ok
+                  'Ok value
                 else
                   'Error { message = "empty string" }
               else
                 'Error { message = "not a string" }
-          )
-          null,
+          ),
 
     join
       : String -> Array String -> String
@@ -3154,6 +3235,24 @@
       "%
       = fun subs s => %string/contains% s subs,
 
+    compare
+      : String -> String -> [| 'Lesser, 'Equal, 'Greater |]
+      | doc m%"
+        Compares two strings lexicographically.
+
+        # Examples
+
+        ```nickel
+        std.string.compare "abc" "def"
+          => 'Lesser
+        std.string.compare "a" "a"
+          => 'Equal
+        std.string.compare "world" "hello"
+          => 'Greater
+        ```
+      "%
+      = fun a b => %string/compare% a b,
+
     replace
       : String -> String -> String -> String
       | doc m%"
@@ -3508,7 +3607,15 @@
          => true
         ```
         "%
-      = %contract/custom% (fun value => if value then 'Ok else 'Error {}) null,
+      =
+        %contract/custom%
+          (
+            fun _label value =>
+              if value then
+                'Ok value
+              else
+                'Error {}
+          ),
 
     assert_all
       | Array Assert -> Bool
@@ -3815,7 +3922,7 @@
       ```
     "%
     = fun msg =>
-      %contract/custom% (fun _ => 'Error { message = msg }) null,
+      %contract/custom% (fun _label _value => 'Error { message = msg }),
 
   fail_with
     | String -> Dyn
diff --git a/doc/manual/contracts.md b/doc/manual/contracts.md
index 0f7a583c..130de7b3 100644
--- a/doc/manual/contracts.md
+++ b/doc/manual/contracts.md
@@ -205,17 +205,41 @@ In some situations, even validators aren't sufficient. For example, when writing
 contracts](#parametrized-contracts), you might not be able to decide right away
 if a value satisfies a contract.
 
-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`.
-
-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).
+For those use-cases, the stdlib provides the additional contract constructor
+`std.contract.custom` which is the most general form (predicates and validators
+are just special cases of general custom contracts).
+
+The argument of `std.contract.custom` must be a function that takes two
+arguments:
+
+- A `label`.
+- The value being checked.
+
+This function must return either `'Ok new_value` or `'Error {..}`, almost like a
+validator, but with the additional `new_value` which is the original value with
+potential delayed checks included.
+
+#### Label
+
+For immediate checks, signaling failure should be done by returning `'Error`, as
+for validators, whenever possible. However, for delayed checks, we must do
+differently. 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. You can think of it as throwing an exception, but one that
+can't be caught.
+
+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.
+
+The use of labels is illustrated in the [delayed contracts
+section](#delayed-contracts). Another example of a general custom contracts is
+given in the next section on parametrized contracts.
 
 ### Parametrized contracts
 
@@ -300,34 +324,33 @@ in
 
 Contracts parametrized by other contracts are not really special amongst
 parametrized contracts, but note that although contracts can be built from
-functions, we will see soon that they can be different objects as well. Contract
+functions, they can be different objects as well, such as record. Contract
 application behaves differently than bare function application. Thus, when
 manually handling another unknown contract `Contract`, do not apply it as a
 function `Contract label value`, but use `std.contract.apply Contract label
-value` instead.
+value` or `std.contract.apply_as_custom Contract label value` instead (the
+difference between both variants is explained in the next subsection).
 
 Parameters that are also contracts are usually generic. In particular, they are
-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`.
+not necessarily predicates or validators but can contain delayed checks. In
+consequence, a contract parametrized by another unknown contract must usually be
+written as a [general custom contract](#general-custom-contracts) using the
+constructor `std.contract.custom`.
 
-One example is the `Nullable` contract, which accepts a value that is either
+One example is a `Nullable` contract, which accepts a value that is either
 `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`.
+value immediately (and return it unchanged). Otherwise, we proceed with the
+argument contract.
 
 ```nickel
 let Nullable = fun Contract =>
   std.contract.custom
-    (fun value =>
+    (fun label value =>
       if value == null then
-        'Ok
+        'Ok value
       else
-        'Proceed
+        std.contract.apply_as_custom Contract label value
     )
-    (std.contract.apply Contract)
 in
 
 [
@@ -340,6 +363,85 @@ in
 ]
 ```
 
+#### `apply` vs `apply_as_custom`
+
+The stdlib provides two variants for applying a contract. The difference lies in
+their return value, and how they propagate errors.
+
+`std.contract.apply` is used by the interpreter when evaluating a contract
+application such as `value | Contract`. As such, a contract violation is always
+turned into a call to `std.contract.blame` and aborts the execution immediately.
+In particular, even an immediate error can't be caught from normal Nickel code.
+If the contract succeeds, or at least the immediate part, the value with
+potential delayed check inside is returned directly.
+
+When implementing a parametrized contract, `apply` should be used for [delayed
+checks](#delayed-contracts). Here is a contrived example re-implementing the
+builtin contract `[| 'Foo Contract |]` parametrized by `Contract`:
+
+```nickel #repl
+> let FooOf = fun Contract =>
+  std.contract.custom (fun label => match {
+    'Foo arg => 'Ok ('Foo (std.contract.apply Contract label arg)),
+    _ => 'Error {},
+  })
+
+
+> 'Foo 5 | FooOf Number
+'Foo 5
+
+> 'Foo "a" | FooOf Number
+error: contract broken by a value
+[...]
+```
+
+In this case, `Contract` is applied as part of the delayed checks. When those
+checks are eventually run, we aren't in the context of the implementation of a
+contract anymore, and `'Ok` or `'Error` aren't meaningful. We need to either
+abort upon failure, or to proceed transparently with the evaluation of `value`.
+
+On the other hand, `std.contract.apply_as_custom` should be used in the
+situation where a parametrized contract performs some immediate checks and then
+completely transfers the execution to other contracts. This is precisely the
+case of the `Nullable` example above:
+
+```nickel #repl
+> let Nullable = fun Contract =>
+    std.contract.custom
+      (fun label value =>
+        if value == null then
+          'Ok value
+        else
+          std.contract.apply_as_custom Contract label value
+      )
+
+> null | Nullable Number
+null
+
+> 5 | Nullable Number
+5
+
+> "a" | Nullable Number
+error: contract broken by a value
+[...]
+```
+
+In this case, we do want the contract application to return either `'Ok` or
+`'Error`. Indeed, we haven't decided yet if we should return `'Ok` or not:
+`Contract` might itself returns an immediate error through `'Error` which we
+would like to propagate.
+
+Not only `apply_as_custom` spares us from wrapping the result in `'Ok`, but the
+most important benefit is to make more contract error catchable by propagating
+`'Error`. For example, with the current version, `Nullable Number` is an
+immediate contract that returns `'Error` if the value is neither `null` nor a
+`Number` (say, `"a"`). Such an immediate contract plays well with boolean
+operations on contracts, can be converted back to a predicate, etc.
+
+Had we use `std.contract.apply` in this case, this would turn the `Number`
+sub-check to a delayed check, and applying the contract to `"a"` would now throw
+an uncatchable contract error.
+
 ## Compound contracts
 
 This section describes the different ways of composing existing contracts
@@ -499,14 +601,14 @@ example:
 
 > {data = "", must_be_very_secure = false} | Secure
 error: non mergeable terms
-  ┌─ <repl-input-15>:1:36
+  ┌─ <repl-input-21>:1:36
   │
 1 │  {data = "", must_be_very_secure = false} | Secure
   │                                    ^^^^^    ------ originally merged here
   │                                    │
   │                                    cannot merge this expression
   │
-  ┌─ <repl-input-13>:2:34
+  ┌─ <repl-input-19>:2:34
   │
 2 │     must_be_very_secure | Bool = true,
   │                                  ^^^^ with this expression
@@ -573,7 +675,7 @@ contract to each element:
 
 > [1000, 10001, 2] | Array VeryBig
 error: contract broken by a value
-  ┌─ <repl-input-21>:1:16
+  ┌─ <repl-input-27>:1:16
   │
 1 │  [1000, 10001, 2] | Array VeryBig
   │                ^          ------- expected array element type
@@ -641,7 +743,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-24>:1:29
+  ┌─ <repl-input-30>:1:29
   │
 1 │  let apply_fun | (Number -> Number) -> Number = fun f => f 0 in
   │                             ------ expected return type of a function provided by the caller
@@ -679,13 +781,9 @@ notions are strictly equivalent.
 ### Lazy evaluation
 
 In the [section on writing a custom contract with
-`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.
-
-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.
+`std.contract.custom`](#general-custom-contracts), we noted the surprising fact
+that a general custom contract must return `'Ok new_value`, instead of just
+returning `'Ok`.
 
 The motivation for this return value is laziness. Nickel is designed to be
 *lazy*, only evaluating values on-demand.
@@ -723,43 +821,27 @@ 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
+combinators, such as 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:
+It's useful to see a general contract as having two parts:
 
-- the *immediate part* which is evaluated first and produces an answer right
+- An **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
+  - `'Ok new_value` to signal success of the immediate checks, returning
+      the delayed part in `new_value`
   - `'Error {..}` to signal immediate failure
+- A **delayed part** included in `new_value` which corresponds to the
+    original value with delayed checks integrated inside. Those checks will fire
+    only when further data is requested. Throwing an error from the delayed part
+    is done using `std.contract.blame`, which is more like throwing an
+    exception, as opposed to simply returning `'Error` in the immediate part.
 
-  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
-  ```
+In practice, a general custom contract is just one function combining both. The
+immediate/delayed distinction is just conceptual, but it's still a helpful point
+of view to understand the design of Nickel's contract system.
 
 Take the record contract `{foo | FooContract}` applied to the value `{foo =
 42}`:
@@ -771,8 +853,7 @@ Take the record contract `{foo | FooContract}` applied to the value `{foo =
 
 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.
+immediate part.
 
 ### Writing a delayed contract
 
@@ -785,27 +866,9 @@ 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.
-
-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.
+Because the parameter contract is generic and can have a delayed part as well,
+`Nullable` needs to be written as a contract with a potential delayed part as
+well.
 
 Imagine we want to write a contract similar to `{_ | Bool}`, that is a
 dictionary of booleans, but we also want keys to be valid numbers (although
@@ -822,7 +885,21 @@ value which is wrapping the original value with delayed checks inside**:
 {
   NumberBoolDict =
     std.contract.custom
-      (fun value =>
+      (fun label value =>
+        let with_delayed_checks =
+          value
+          |> std.record.map
+            (
+              fun name value =>
+                let label_with_msg =
+                  std.contract.label.with_message "field `%{name}` is not a boolean" label
+                in
+                # Note: we use `apply` and not `apply_as_custom` here since we
+                # are inside a delayed check
+                std.contract.apply Bool label_with_msg value
+            )
+        in
+
         if std.is_record value then
           value
           |> std.record.fields
@@ -835,74 +912,49 @@ value which is wrapping the original value with delayed checks inside**:
                   message = "field name `%{field_name}` is not a number"
                 }
             )
-            'Proceed
+            ('Ok with_delayed_checks)
         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.
 
-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.
+We first define `with_delayed_checks`, the delayed part, that maps a function
+over the record which substitutes each field for the same value but wrapped in a
+`Bool` contract. Thanks to lazyness, the record mapping won't happen until `'Ok
+with_delayed_checks` is returned *and* some code actually uses the value. Even
+then, 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 individual fields will be accessed or exported.
+
+We proceed to the immediate checks. We first check if the value is a record and
+return `'Error {..}` otherwise. Then, we iterate 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 `'Ok with_delayed_checks`, which will be
+picked only if all the fields are valid and the iteration goes through the end.
+In that case, the immediate part has succeeded, and we return the value with
+delayed checks.
+
+We could theoretically have made the whole contact delayed by pushing the
+immediate 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 immediately, to report errors early
+and because it behaves better with respect to some contract operations.
 
 Let us see if we indeed preserved laziness:
 
 ```nickel #repl
-#hide-range{1-31}
+#hide-range{1-30}
 
 > let NumberBoolDict =
-    std.contract.custom
-      (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 =>
+  std.contract.custom
+    (fun label value =>
+      let with_delayed_checks =
         value
         |> std.record.map
           (
@@ -912,7 +964,23 @@ Let us see if we indeed preserved laziness:
               in
               std.contract.apply Bool label_with_msg value
           )
-      )
+      in
+      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"
+              }
+          )
+          ('Ok with_delayed_checks)
+      else
+        'Error { message = "not a record" }
+    )
 
 > let config | NumberBoolDict = {
     "1" | std.FailWith "ooch" = null, # same as our previous "fail"
@@ -927,28 +995,12 @@ Yes! Our contract doesn't unduly cause the evaluation of the field `"1"`. Does
 it check anything, though?
 
 ```nickel #repl
-#hide-range{1-31}
+#hide-range{1-30}
 
 > let NumberBoolDict =
-    std.contract.custom
-      (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 =>
+  std.contract.custom
+    (fun label value =>
+      let with_delayed_checks =
         value
         |> std.record.map
           (
@@ -958,7 +1010,23 @@ it check anything, though?
               in
               std.contract.apply Bool label_with_msg value
           )
-      )
+      in
+      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"
+              }
+          )
+          ('Ok with_delayed_checks)
+      else
+        'Error { message = "not a record" }
+    )
 
 > let config | NumberBoolDict = {
     not_a_number = false,
@@ -982,7 +1050,7 @@ error: contract broken by a value
 
 It does!
 
-#### Conclusion
+### Conclusion
 
 Our `NumberBoolDict` contract doesn't perform all the checks needed right away.
 Instead, it performs some of them immediately and **returns a new value, which
diff --git a/doc/manual/typing.md b/doc/manual/typing.md
index 9e0fd84d..7163ab19 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>:378:25
+    ┌─ <stdlib/std.ncl>:377:25
     │
-378 │       : forall a. (a -> Bool) -> Array a -> Array a
+377 │       : 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/config-gcc.ncl b/examples/config-gcc/config-gcc.ncl
index 20e79938..58e39a16 100644
--- a/examples/config-gcc/config-gcc.ncl
+++ b/examples/config-gcc/config-gcc.ncl
@@ -12,25 +12,23 @@ let GccFlag =
 
   std.contract.custom
     (
-      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" },
-      }
+      fun label =>
+        match {
+          value if std.is_string value && is_valid_flag value => 'Ok value,
+          { flag, arg } if std.array.elem flag supported_flags =>
+            # We normalize a record representation to a string
+            'Ok "%{flag}%{arg}",
+          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/notes/fixing-sealing-and-recursive-records.md b/notes/fixing-sealing-and-recursive-records.md
index 2ca60700..63b08c12 100644
--- a/notes/fixing-sealing-and-recursive-records.md
+++ b/notes/fixing-sealing-and-recursive-records.md
@@ -120,14 +120,14 @@ Final result:
 Run of a `%record/map% { foo | Num =  1, bar | PosNat | Even = foo + 1} (fun _key ((+) 1)`
 
 ```
-App(Op1(record_map, record), function)
-Op1(record_map, record); {stack..}
+App(Op1(record/map, record), function)
+Op1(record/map, record); {stack..}
 record ~ RecRecord {..}
 
 RECORD{ foo |pending Num = %1, bar |pending [PosNat,Even] = %2}
-%2 := (%apply_contract% Num label %1) + 1
+%2 := (%contract/apply% Num label %1) + 1
 
-RECORD{ foo = f (%apply_contract% Num %1), bar = f (%apply_contract% PosNat,Even %2) }
+RECORD{ foo = f (%contract/apply% Num %1), bar = f (%contract/apply% PosNat,Even %2) }
 
 # With a call to pending_contract.dualize()
 


Test patch:
diff --git a/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl b/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl
index 0647ecfa..e485845d 100644
--- a/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl
+++ b/cli/tests/snapshot/inputs/errors/validator_custom_error.ncl
@@ -2,7 +2,7 @@
 # command = ['eval']
 let Is42 = std.contract.from_validator (fun value =>
   if value == 42 then
-    'Ok value
+    'Ok
   else
     'Error {
       message = "Value must be 42",
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 7591a62b..401ef8b1 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>:167:9
+    ┌─ <stdlib/std.ncl>:166:9
     │
-167 │       | std.contract.unstable.IndexedArrayFun 'Index
+166 │       | 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 89879ec0..7e00925c 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>:167:9
+    ┌─ <stdlib/std.ncl>:166:9
     │
-167 │       | std.contract.unstable.IndexedArrayFun 'Index
+166 │       | 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 64c97a07..2ba94fc5 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>:678:9
+    ┌─ <stdlib/std.ncl>:705:9
     │
-678 │       | std.contract.unstable.RangeFun Dyn
+705 │       | 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 b0ff5a84..621bed3b 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>:653:9
+    ┌─ <stdlib/std.ncl>:680:9
     │
-653 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
+680 │       | 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 ee32a042..d958d9dc 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,10 @@ source: cli/tests/snapshot/main.rs
 expression: err
 ---
 error: contract broken by the caller of `map`
-    ┌─ <stdlib/std.ncl>:151:33
+       expected an array
+    ┌─ <stdlib/std.ncl>:150:33
     │
-151 │       : forall a b. (a -> b) -> Array a -> Array b
+150 │       : 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/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_access.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_access.ncl.snap
index 295a4d5f..ff8a51a6 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_access.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_access.ncl.snap
@@ -2,6 +2,6 @@
 source: cli/tests/snapshot/main.rs
 expression: err
 ---
-error: record_insert: tried to extend a record with the field bar, but it already exists
+error: record/insert: tried to extend a record with the field bar, but it already exists
 
 
diff --git a/core/tests/integration/inputs/contracts/custom_generic_fail.ncl b/core/tests/integration/inputs/contracts/custom_generic_fail.ncl
index 1ea2bd72..3af2c26e 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 _ => 'Proceed) (fun label _ => std.contract.blame label) in
+let AlwaysFail = std.contract.custom (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
index e4a935db..5c7e0eb6 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_immediate_fail.ncl
@@ -2,5 +2,5 @@
 #
 # [test.metadata]
 # error = 'EvalError::BlameError'
-let AlwaysFail = std.contract.custom (fun _ => 'Error {}) (fun _ value => value) in
+let AlwaysFail = std.contract.custom (fun _ _ => 'Error {}) 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 f75f7703..eef318df 100644
--- a/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
+++ b/core/tests/integration/inputs/contracts/custom_generic_succeed.ncl
@@ -1,9 +1,7 @@
 # test.type = 'pass'
-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
+let AlwaysSucceed = std.contract.custom (fun _ value => 'Ok value) in
 
 (
   3
-    | AlwaysSucceedImmediate
-    | AlwaysSucceedFull
+    | AlwaysSucceed
 ) == 3
diff --git a/core/tests/integration/inputs/typecheck/array_subtyping.ncl b/core/tests/integration/inputs/typecheck/array_subtyping.ncl
new file mode 100644
index 00000000..788bbd0f
--- /dev/null
+++ b/core/tests/integration/inputs/typecheck/array_subtyping.ncl
@@ -0,0 +1,5 @@
+# test.type = 'pass'
+let test : Array {foo : Number} = [{foo = 5}] in
+let test_func : Array {_ : Number} -> Array {_ : Number} = fun a => a in 
+let result : Array {_ : Number} = test_func test in 
+true