Is there a way to enforce uniqueness of arbitrary key-value pairs in a JSONB object column
Failed attempt with Exclusion Constraints

I don't believe this is possible to do this with exclusion DDL because I don't believe PostgreSQL supports exclusion constraints on text[]

CREATE TABLE categories(title)
AS VALUES
  ('{"en": "Hello", "ru": "Привет"}'::jsonb);


Helper function

CREATE FUNCTION jsonb_object_keys_as_array(j JSONB)
RETURNS text[]
AS $$
  SELECT ARRAY( SELECT jsonb_object_keys(j) AS d );
$$
LANGUAGE sql
IMMUTABLE;


Exclusion constraint attempt.

ALTER TABLE categories
  ADD EXCLUDE
  USING gist (jsonb_object_keys_as_array(title) WITH &amp;&amp;);

ERROR:  data type text[] has no default operator class for access method "gist"
HINT:  You must specify an operator class for the index or define a default operator class for the data type.


I'm asking a more specific question on this right now.

Other solutions

As it pertains to your problem, I believe currently you'll have to resort to a less than optimal solution like,


Implement this with triggers.
Create a function that may or may not use a table or enum to resolve text -&gt; int and then map over text[] returning int[] and use intarray which will permit a GIST index and &amp;&amp; 


Or, of course, you can just fix your schema.

Trigger

This method should also be able to make use of a GIST index on catagories(title)

CREATE OR REPLACE FUNCTION resist_duplicate_languages()
RETURNS TRIGGER
AS $$
  DECLARE
    collision bool DEFAULT false;
  BEGIN
    EXECUTE
      FORMAT('SELECT true FROM %I.%I WHERE ctid &lt;&gt; $1 AND title ?| jsonb_object_keys_as_array($2) LIMIT 1', TG_TABLE_SCHEMA, TG_TABLE_NAME )
      INTO collision
      USING NEW.ctid, NEW.title;

    IF collision IS true THEN
      RAISE EXCEPTION 'Can not collide on language %.%(title) languages %', TG_TABLE_SCHEMA, TG_TABLE_NAME, jsonb_object_keys_as_array(NEW.title);
    END IF;

    RETURN null;
  END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER resist_duplicate_languages
  AFTER INSERT OR UPDATE OF title
  ON categories
  FOR EACH ROW
  EXECUTE PROCEDURE resist_duplicate_languages();


Testing

//works
INSERT INTO categories VALUES ('{"fr":"We surrender and eat cheese"}');

//does not work a second time
INSERT INTO categories VALUES ('{"fr":"We surrender and eat cheese"}');
ERROR:  Can not collide on language public.categories(title) languages {fr}