makeEncodeToSmallcaps = (encodeOptions = {}) => {
  const {
    encodeRemotableToSmallcaps = dontEncodeRemotableToSmallcaps,
    encodePromiseToSmallcaps = dontEncodePromiseToSmallcaps,
    encodeErrorToSmallcaps = dontEncodeErrorToSmallcaps,
  } = encodeOptions;

  const assertEncodedError = encoding => {
    (typeof encoding === 'object' && hasOwnPropertyOf(encoding, '#error')) ||
      Fail`internal: Error encoding must have "#error" property: ${q(
        encoding,
      )}`;
    // Assert that the #error property decodes to a string.
    const message = encoding['#error'];
    (typeof message === 'string' &&
      (!startsSpecial(message) || message.charAt(0) === '!')) ||
      Fail`internal: Error encoding must have string message: ${q(message)}`;
  };

  /**
   * Must encode `val` into plain JSON data *canonically*, such that
   * `JSON.stringify(encode(v1)) === JSON.stringify(encode(v1))`. For most
   * encodings, the order of properties of each node of the output
   * structure is determined by the algorithm below without special
   * arrangement, usually by being expressed directly as an object literal.
   * The exception is copyRecords, whose natural enumeration order
   * can differ between copyRecords that our distributed object semantics
   * considers to be equivalent.
   * Since, for each copyRecord, we only accept string property names,
   * not symbols, we can canonically sort the names first.
   * JSON.stringify will then visit these in that sorted order.
   *
   * Encoding with a canonical-JSON encoder would also solve this canonicalness
   * problem in a more modular and encapsulated manner. Note that the
   * actual order produced here, though it agrees with canonical-JSON on
   * copyRecord property ordering, differs from canonical-JSON as a whole
   * in that the other record properties are visited in the order in which
   * they are literally written below. TODO perhaps we should indeed switch
   * to a canonical JSON encoder, and not delicately depend on the order
   * in which these object literals are written.
   *
   * Readers must not care about this order anyway. We impose this requirement
   * mainly to reduce non-determinism exposed outside a vat.
   *
   * @param {any} passable
   * @returns {SmallcapsEncoding} except that `encodeToSmallcaps` does not generally
   * `harden` this result before returning. Rather, `encodeToSmallcaps` is not
   * directly exposed.
   * What's exposed instead is a wrapper that freezes the output before
   * returning. If this turns out to impede static analysis for `harden` safety,
   * we can always put the (now redundant) hardens back in. They don't hurt.
   */
  const encodeToSmallcapsRecur = passable => {
    // First we handle all primitives. Some can be represented directly as
    // JSON, and some must be encoded into smallcaps strings.
    const passStyle = passStyleOf(passable);
    switch (passStyle) {
      case 'null':
      case 'boolean': {
        // pass through to JSON
        return passable;
      }
      case 'string': {
        if (startsSpecial(passable)) {
          // Strings that start with a special char are quoted with `!`.
          // Since `!` is itself a special character, this trivially does
          // the Hilbert hotel. Also, since the special characters are
          // a continuous subrange of ascii, this quoting is sort-order
          // preserving.
          return `!${passable}`;
        }
        // All other strings pass through to JSON
        return passable;
      }
      case 'undefined': {
        return '#undefined';
      }
      case 'number': {
        // Special-case numbers with no digit-based representation.
        if (Number.isNaN(passable)) {
          return '#NaN';
        } else if (passable === Infinity) {
          return '#Infinity';
        } else if (passable === -Infinity) {
          return '#-Infinity';
        }
        // Pass through everything else, replacing -0 with 0.
        return is(passable, -0) ? 0 : passable;
      }
      case 'bigint': {
        const str = String(passable);
        return /** @type {bigint} */ (passable) < 0n ? str : `+${str}`;
      }
      case 'symbol': {
        assertPassableSymbol(passable);
        const name = /** @type {string} */ (nameForPassableSymbol(passable));
        return `%${name}`;
      }
      case 'copyRecord': {
        // Currently copyRecord allows only string keys so this will
        // work. If we allow sortable symbol keys, this will need to
        // become more interesting.
        const names = ownKeys(passable).sort();
        return fromEntries(
          names.map(name => [
            encodeToSmallcapsRecur(name),
            encodeToSmallcapsRecur(passable[name]),
          ]),
        );
      }
      case 'copyArray': {
        return passable.map(encodeToSmallcapsRecur);
      }
      case 'byteArray': {
        // TODO implement
        throw Fail`marsal of byteArray not yet implemented: ${passable}`;
      }
      case 'tagged': {
        return {
          '#tag': encodeToSmallcapsRecur(getTag(passable)),
          payload: encodeToSmallcapsRecur(passable.payload),
        };
      }
      case 'remotable': {
        const result = encodeRemotableToSmallcaps(
          passable,
          encodeToSmallcapsRecur,
        );
        if (typeof result === 'string' && result.charAt(0) === '$') {
          return result;
        }
        // `throw` is noop since `Fail` throws. But linter confused
        throw Fail`internal: Remotable encoding must start with "$": ${result}`;
      }
      case 'promise': {
        const result = encodePromiseToSmallcaps(
          passable,
          encodeToSmallcapsRecur,
        );
        if (typeof result === 'string' && result.charAt(0) === '&') {
          return result;
        }
        throw Fail`internal: Promise encoding must start with "&": ${result}`;
      }
      case 'error': {
        const result = encodeErrorToSmallcaps(passable, encodeToSmallcapsRecur);
        assertEncodedError(result);
        return result;
      }
      default: {
        throw assert.fail(
          X`internal: Unrecognized passStyle ${q(passStyle)}`,
          TypeError,
        );
      }
    }
  };
  const encodeToSmallcaps = passable => {
    if (isErrorLike(passable)) {
      // We pull out this special case to accommodate errors that are not
      // valid Passables. For example, because they're not frozen.
      // The special case can only ever apply at the root, and therefore
      // outside the recursion, since an error could only be deeper in
      // a passable structure if it were passable.
      //
      // We pull out this special case because, for these errors, we're much
      // more interested in reporting whatever diagnostic information they
      // carry than we are about reporting problems encountered in reporting
      // this information.
      const result = harden(
        encodeErrorToSmallcaps(passable, encodeToSmallcapsRecur),
      );
      assertEncodedError(result);
      return result;
    }
    return harden(encodeToSmallcapsRecur(passable));
  };
  return harden(encodeToSmallcaps);
}
@param {EncodeToSmallcapsOptions} [encodeOptions]
encodeOptions is actually optional, but not marked as such to work around
https://github.com/microsoft/TypeScript/issues/50286

@returns {(passable: Passable) => SmallcapsEncoding}