The moment-interval overloads of the "+" and "-" operators for timestamptz, timestamp, and time

This page documents a preview version. v2.23 Preview
Preview includes features under active development and is for development and testing only.
For production, use the latest stable version (v2024.1).
Download and install the date-time utilities code.
The code on this page depends on the code presented in the section User-defined interval utility functions. This is included in the larger code kit that includes all of the reusable code that the overall date-time section describes and uses.

This section presents PL/pgSQL implementations that model the semantics of the interval-moment-overload of the + operator for three distinct kinds of interval value: pure seconds, pure days, and pure months. The semantics of the interval-moment-overloads of the - operator is implied by the semantics of the overload of the + operator.

The code examples that this page shows elaborate on those that are shown on the main parent page The interval data type and its variants.

Defining three kinds of interval value: pure seconds, pure days, and pure months.

First, do this:

select (
    '30 days '::interval = '720 hours'::interval and
    ' 1 month'::interval =   '30 days'::interval and
    ' 1 month'::interval = '720 hours'::interval
  )::text;

The result is true. Now set up the test like this:

drop table if exists t;
create table t(
   t0               timestamptz primary key,
  "t0 + 720 hours"  timestamptz,
  "t0 + 30 days"    timestamptz,
  "t0 + 1 month"    timestamptz);

-- Insensitive to the session TimeZone setting.
insert into t(t0) values ('2021-02-19 12:00:00 America/Los_Angeles');

deallocate all;

prepare update_table as
update t set
  "t0 + 720 hours" = t0 + '720 hours'::interval,
  "t0 + 30 days"   = t0 +   '30 days'::interval,
  "t0 + 1 month"   = t0 +   '1 month'::interval;

prepare inspect_result as
select
  t0,
  "t0 + 720 hours",
  "t0 + 30 days",
  "t0 + 1 month"
from t;

Now execute the test with the session timezone set to one that respects Daylight savings Time. In the 'America/Los_Angeles' zone, this starts at 02:00 in the small hours of Sunday morning on 14-March-2021—and so, with the chosen starting moment, each of the differently spelled interval values spans the "spring forward" moment. Notice, too, that the starting moment is chosen so that the duration contains the end of February and that February never has thirty days, leap year or not.

set timezone = 'America/Los_Angeles';
execute update_table;
execute inspect_result;

This is the result:

           t0           |     t0 + 720 hours     |      t0 + 30 days      |      t0 + 1 month
------------------------+------------------------+------------------------+------------------------
 2021-02-19 12:00:00-08 | 2021-03-21 13:00:00-07 | 2021-03-21 12:00:00-07 | 2021-03-19 12:00:00-07

All the three results are displayed with a negative seven hours UTC offset reflecting the fact that they occur during the Daylight Savings Time period in the United States. The time of day for "t0 + 720 hours" differs by one hour from that for "t0 + 30 days", but the date component is the same for each. The time of day for "t0 + 30 days" is the same as that for "t0 + 1 month", but the date component differs by two days.

You have to think quite hard to understand displayed timestamptz values when you inspect them using a session whose TimeZone setting is not UTC because that setting affects the values that you see. This is very much intended and reflects the raison d’être of the data type. See the section The plain timestamp and timestamptz data types. In particular, this is why the moment before "spring forward" is displayed with an offset of negative eight hours while the moments after "spring forward" are displayed with an offset of negative seven hours.

The semantics that brings the outcome that you see here was designed for a common use case:

  • Look for "Are you postponing an appointment by one day... or are you making a journey (like a flight) that you know takes twenty-four hours?" and, later, "Are you setting up reminders to tell you to water your hardy succulents every month...or are you taking a package tour that is advertised to last thirty days?" in the section Interval arithmetic semantics on the main parent page The interval data type and its variants.

Here, you conceive of the problem in the timezone where you are (and maybe, too, in the timezone where you'll end up) and you want to know what a clock on the wall where you happen to be will show when you look at it. So the application that implements the problem's solution will ensure that it sets the session's timezone appropriately.

Bear in mind that many different displayed timestamptz values can all correspond to the same underlying representation because, for example, '2021-02-19 12:00:00-08' (noon on the US West Coast in winter) is the same absolute moment as '2021-02-19 15:00:00-05' (three o'clock in the afternoon on the same date on the US East Coast). To save yourself the effort of mental arithmetic, it's best to observe the results of tests like the present one using a session whose timezone is set to UTC'—which has an offset of zero, meaning that the bare time-of-day conveys all the information.

Inspect the outcome using UTC:

set timezone = 'UTC';
execute inspect_result;

This is the result:

           t0           |     t0 + 720 hours     |      t0 + 30 days      |      t0 + 1 month
------------------------+------------------------+------------------------+------------------------
 2021-02-19 20:00:00+00 | 2021-03-21 20:00:00+00 | 2021-03-21 19:00:00+00 | 2021-03-19 19:00:00+00

The three interval arithmetic results all differ from each other. in other words, the semantic effect of adding the "same" interval value to the same timestamptz value is different for each of the three nominally interchangeable spellings, '720 hours', '30 days', or '1 month', of the interval value.

Now re-execute the prepared statement after setting the session's timezone to one that does not respect Daylight savings Time.

set timezone = 'Asia/Shanghai';
execute update_table;

Once again, inspect the outcome using UTC:

set timezone = 'UTC';
execute inspect_result;

This is the new result:

           t0           |     t0 + 720 hours     |      t0 + 30 days      |      t0 + 1 month
------------------------+------------------------+------------------------+------------------------
 2021-02-19 20:00:00+00 | 2021-03-21 20:00:00+00 | 2021-03-21 20:00:00+00 | 2021-03-19 20:00:00+00

This time, the columns "t0 + 720 hours" and "t0 + 30 days" have the same value as each other while these still differ from the "t0 + 1 month" value.

The reason that the three different interval value spellings can have different effects is given by comparing them using the user-defined "strict equals" interval-interval == operator.

select (
    not '30 days '::interval == '720 hours'::interval and
    not ' 1 month'::interval == ' 30 days '::interval and
    not ' 1 month'::interval == '720 hours'::interval
  )::text;

The result is true—meaning that no two from the three differently spelled interval values are equal in the strict sense. Try this to see yet more vividly what lies behind this outcome:

select
  interval_mm_dd_ss('720 hours'::interval) as "'720 hours'",
  interval_mm_dd_ss(' 30 days '::interval) as "'30 days'",
  interval_mm_dd_ss('  1 month'::interval) as "'1 month'";

This is the result:

  '720 hours'  | '30 days' | '1 month'
---------------+-----------+-----------
 (0,0,2592000) | (0,30,0)  | (1,0,0)

Each of the different spellings of the "same" interval value ("same, that is, with respect to the criterion for equality that the native interval-interval overload of = implements) is either a pure interval value with respectively:

  • either just a non-zero ss field (a.k.a. a "pure seconds" interval value)
  • or just a non-zero dd field (a.k.a. a "pure days" interval value)
  • or just a non-zero mm field (a.k.a. a "pure months" interval value).

This explains why the arithmetic semantics is different for the three kinds of pure interval value.

  • The outcomes with a "pure seconds" interval value and a "pure days" interval value will differ from each other when they are used in a session whose TimeZone setting respects Daylight Savings Time and the durations of the interval values span the "spring forward" moment or the "fall back" moment (as long as the moments have the data type timestamptz).
  • The outcomes with a "pure days" interval value and a "pure months" interval value will differ from each other when the durations of the interval values span the boundaries of months whose number of days differs from thirty. This will be the case irrespective of whether the session's timezone respects Daylight Savings Time and of whether the moments have the data type timestamptz or plain timestamp.

Hybrid interval arithmetic is dangerous

The term hybrid interval value will be used to denote an interval value where more than one of the fields of the [mm, dd, ss] internal representation is non-zero.

Try this. It uses hybrid "days-seconds" _interval values.

drop function if exists hybrid_dd_ss_results() cascade;

create function hybrid_dd_ss_results()
  returns table(x text)
  language plpgsql
as $body$
begin
  set timezone = 'America/Los_Angeles';
  declare
    -- Five hours before DST Start.
    t0 constant timestamptz := '2021-03-13 21:00:00 America/Los_Angeles';

    "1 day"          constant interval := '1 day';
    "9 hours"        constant interval := '9 hours';
    "1 day 9 hours"  constant interval := '1 day 9 hours';
  begin
    x := $$ t0 + '1 day 9 hours'      : $$|| (t0 + "1 day 9 hours")      ::text;  return next;
    x := $$(t0 + '1 day') + '9 hours' : $$||((t0 + "1 day") + "9 hours") ::text;  return next;
    x := $$(t0 + '9 hours') + '1 day' : $$||((t0 + "9 hours") + "1 day") ::text;  return next;
  end;
end;
$body$;

select x from hybrid_dd_ss_results();

This is the result:

  t0 + '1 day 9 hours'      : 2021-03-15 06:00:00-07
 (t0 + '1 day') + '9 hours' : 2021-03-15 06:00:00-07
 (t0 + '9 hours') + '1 day' : 2021-03-15 07:00:00-07

Notice that the time of day for the third result, 07:00, is different from that for the first and second results, 06:00.

Now try this similar test. It uses hybrid "months-days" _interval values.

drop function if exists hybrid_mm_dd_results() cascade;

create function hybrid_mm_dd_results()
  returns table(x text)
  language plpgsql
as $body$
begin
  set timezone = 'UTC';
  declare
    -- The last day of a short month.
    t0 constant timestamptz := '2021-02-28 12:00:00 UTC';

    "1 month"         constant interval := '1 month';
    "9 days"          constant interval := '9 days';
    "1 month 9 days"  constant interval := '1 month 9 days';
  begin
    x := $$ t0 + '1 month 9 days'      : $$|| (t0 + "1 month 9 days")       ::text;  return next;
    x := $$(t0 + '1 month') + '9 days' : $$||((t0 + "1 month") + "9 days")  ::text;  return next;
    x := $$(t0 + '9 days') + '1 month' : $$||((t0 + "9 days") + "1 month")  ::text;  return next;
  end;
end;
$body$;

select x from hybrid_mm_dd_results();

This is the result:

  t0 + '1 month 9 days'      : 2021-04-06 12:00:00+00
 (t0 + '1 month') + '9 days' : 2021-04-06 12:00:00+00
 (t0 + '9 days') + '1 month' : 2021-04-09 12:00:00+00

Notice that the date for the third result, 9-April, is different from that for the first and second results, 6-April.

You might be tempted to speculate about priority rules for how the fields of a hybrid interval value are acted on. Don't do this. The PostgreSQL documentation doesn't state the rules and application code that used hybrid interval values would be hard to understand and likely, therefore, to be unreliable.

Avoid arithmetic that uses hybrid 'interval' values.
Yugabyte recommends that you avoid creating and using hybrid interval values; rather, you should adopt the practice that the section Custom domain types for specializing the native interval functionality explains.

The semantics for the moment-interval overloads of "+" and "-" for pure seconds interval values

Create this function:

drop function if exists moment_ss_interval_addition(timestamptz, interval) cascade;

create function moment_ss_interval_addition(t timestamptz, i interval)
  returns timestamptz
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
begin
  assert
    mm_dd_ss.ss <> 0 and
    mm_dd_ss.dd =  0 and
    mm_dd_ss.mm =  0,
    'not a pure seconds interval';
  declare
    ss_t     constant double precision not null := extract(epoch from t);
    ss_i     constant double precision not null := mm_dd_ss.ss::double precision;
    i_model  constant timestamptz      not null := to_timestamp(ss_t + ss_i);

    i_actual constant timestamptz      not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Now test it like this:

set timezone = 'America/Los_Angeles';
select moment_ss_interval_addition('2021-02-19 12:00:00 America/Los_Angeles'::timestamptz, '720 hours'::interval);

The function finishes without an assert violation—showing that a pure seconds interval value was supplied and that the semantic model that the function describes holds. This is the result:

 2021-03-21 13:00:00-07

The PL/pgSQL code, by simply adding a number of seconds to the start moment to get the end moment in the regime of seconds since the start of the epoch, vividly makes the point that the moment-interval overloads of + and - for pure seconds interval values honor clock-time-semantics.

Note: YSQL inherits the fact from the PostgreSQL implementation of the proleptic Gregorian calendar that leap seconds are not supported. If they were, then the explanation of the semantics of moment-interval would demand yet more care.

Now test it using a timezone that does not respect Daylight Savings time:

set timezone = 'Asia/Shanghai';
select moment_ss_interval_addition('2021-02-19 12:00:00 America/Los_Angeles'::timestamptz, '720 hours'::interval);

Once again, the function finishes without an assert violation—showing that, again, the semantic model that the function describes holds. This is the result:

 2021-03-22 04:00:00+08

The overload of the function moment_ss_interval_addition() for a plain timestamp moment is a more-or-less mechanical re-write of the overload for the timestamptz moment.

Create it thus. (The function to_timestamp_without_tz (double precision) returns timestamp is defined in the User-defined interval utility functions section.)

drop function if exists moment_ss_interval_addition(timestamp, interval) cascade;

create function moment_ss_interval_addition(t timestamp, i interval)
  returns timestamp
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
begin
  assert
    mm_dd_ss.ss <> 0 and
    mm_dd_ss.dd =  0 and
    mm_dd_ss.mm =  0,
    'not a pure seconds interval';
  declare
    ss_t     constant double precision not null := extract(epoch from t);
    ss_i     constant double precision not null := mm_dd_ss.ss::double precision;
    i_model  constant timestamp        not null := to_timestamp_without_tz(ss_t + ss_i);

    i_actual constant timestamp        not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Test it like this:

select moment_ss_interval_addition('2021-02-19 12:00:00'::timestamp, '720 hours'::interval);

Once again, the function finishes without an assert violation—showing that a pure seconds interval value was supplied and that the semantic model that the function describes holds. This is the result:

 2021-03-21 12:00:00

The overload of the function moment_ss_interval_addition() for a plain time moment is derived from the overload for a plain timestamp moment. Create it thus. (The function to_time (double precision) returns time is defined in the User-defined interval utility functions section.)

drop function if exists moment_ss_interval_addition(time, interval) cascade;

create function moment_ss_interval_addition(t time, i interval)
  returns time
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
begin
  assert
    mm_dd_ss.ss <> 0 and
    mm_dd_ss.dd =  0 and
    mm_dd_ss.mm =  0,
    'not a pure seconds interval';
  declare
    -- no. of secs since midnight
    ss_t     constant double precision not null := extract(epoch from t);
    ss_i     constant double precision not null := mm_dd_ss.ss;

    i_model  constant time             not null := to_time(ss_t + ss_i);

    i_actual constant time             not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Test it like this:

select moment_ss_interval_addition('17:43:37.123'::time, '10:13:41'::interval);

Once again, the function finishes without an assert violation—showing that a pure seconds interval value was supplied and that the semantic model that the function describes holds. This is the result:

 03:57:18.123

The semantics for the moment-interval overloads of "+" and "-" for pure days interval values

See the 'sensitivity of timestamptz-interval arithmetic to the current timezone' section for a complementary definition of the semantics of arithmetic that uses pure days 'interval' values.

The explanations that this page presents for the semantics of moment-interval arithmetic for the three different kinds of interval (pure seconds, pure days, and pure months) are oriented to the use-cases that motivate the distinctions. The explanation of the pure days semantics is inextricably bound up with the timezone notion and how this, in turn, determines the UTC offset.

See the section The sensitivity of timestamptz-interval arithmetic to the current timezone in the "Timezones and UTC offsets" section for a code example and discussion that models the rules in a different, but ultimately equivalent, way from this present subsection's approach. For a complex topic like this, it helps to solidify your mental model by examining relevant scenarios from different angles.

Notice that it doesn't make sense to add a duration of one or several days to a time value—even though the attempt doesn't cause an error and does produce a result. This section shows only PL/pgSQL models for the timestamptz and timestamp overloads.

Create this function:

drop function if exists moment_dd_interval_addition(timestamptz, interval) cascade;

create function moment_dd_interval_addition(t timestamptz, i interval)
  returns timestamptz
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
  current_tz text not null := '';
begin
  assert
    mm_dd_ss.ss =  0 and
    mm_dd_ss.dd <> 0 and
    mm_dd_ss.mm =  0,
    'not a pure days interval';
  declare
    time_part    constant text        not null := to_char(t, 'hh24:mi:ss');
    date_part_0  constant date        not null := t::date;
    date_part    constant date        not null := date_part_0 + mm_dd_ss.dd;
    i_model      constant timestamptz not null := (date_part::text||' '||time_part::text)::timestamptz;

    i_actual     constant timestamptz not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Test it thus:

set timezone = 'Asia/Shanghai';
select moment_dd_interval_addition('2021-02-19 12:00:00 Asia/Shanghai'::timestamptz, '30 days'::interval);

The function finishes without an assert violation—showing that a pure days interval value was supplied and that the semantic model that the function describes holds. This is the result:

 2021-03-21 12:00:00+08

The PL/pgSQL code, by separating out the time-of-day before adding the number of days, vividly makes the point that the semantics of the moment-interval overloads of + and - for pure days interval values honors one flavor of calendar-time_semantics.

Now test it using a timezone that does not respect Daylight Savings time:

set timezone = 'Asia/Shanghai';
select moment_dd_interval_addition('2021-02-19 12:00:00 Asia/Shanghai'::timestamptz, '30 days'::interval);

Once again, the function finishes without an assert violation—showing that, again, the semantic model that the function describes holds. This is the result:

 2021-03-21 12:00:00+08

Notice that when you do the operation and observe the result in a certain timezone (this example happens to implement doing and observing with the same statement—but this does not need generally to be the case), then adding some number of days takes you to the same time of day that many days later. The PL/pgSQL model does exactly this. This rule holds whether or nor the timezone that your session uses respects Daylight Savings Time and the interval spans the "spring forward" moment or the "fall back" moment.

The overload of the function moment_dd_interval_addition() for a plain timestamp moment is a direct re-write of the overload for the timestamptz moment. Simply replace the text "timestamptz" with the text "timestamp" in the function's source code:

drop function if exists moment_dd_interval_addition(timestamp, interval) cascade;

create function moment_dd_interval_addition(t timestamp, i interval)
  returns timestamp
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
  current_tz text not null := '';
begin
  assert
    mm_dd_ss.ss =  0 and
    mm_dd_ss.dd <> 0 and
    mm_dd_ss.mm =  0,
    'not a pure days interval';
  declare
    time_part    constant text      not null := to_char(t, 'hh24:mi:ss');
    date_part_0  constant date      not null := t::date;
    date_part    constant date      not null := date_part_0 + mm_dd_ss.dd;
    i_model      constant timestamp not null := (date_part::text||' '||time_part::text)::timestamp;

    i_actual     constant timestamp not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Then test it with trivially modified versions of the tests that were used for the timestamptz overload:

set timezone = 'Asia/Shanghai';
select moment_dd_interval_addition('2021-02-19 12:00:00'::timestamp, '30 days'::interval);

set timezone = 'Asia/Shanghai';
select moment_dd_interval_addition('2021-02-19 12:00:00'::timestamp, '30 days'::interval);

You get the same result for each, independently (of course) of what the sessions' TimeZone setting is:

 2021-03-21 12:00:00

The semantics for the moment-interval overloads of "+" and "-" for pure months interval values

Notice that, here too, it doesn't make sense to add a duration of one or several months to a time value—even though the attempt doesn't cause an error and does produce a result. This section shows only PL/pgSQL models for the timestamptz and timestamp overloads.

Create this function:

drop function if exists moment_mm_interval_addition(timestamptz, interval) cascade;

create function moment_mm_interval_addition(t timestamptz, i interval)
  returns timestamptz
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
  current_tz text not null := '';
begin
  assert
    mm_dd_ss.ss =  0 and
    mm_dd_ss.dd =  0 and
    mm_dd_ss.mm <> 0,
    'not a pure months interval';
  declare
    time_part    constant text        not null := to_char(t, 'hh24:mi:ss');
    date_part_0  constant date        not null := t::date;
    year_0       constant int         not null := extract(year  from date_part_0);
    month_0      constant int         not null := extract(month from date_part_0);
    day_0        constant int         not null := extract(day   from date_part_0);
    year         constant int         not null := year_0  + trunc(mm_dd_ss.mm/12);
    month        constant int         not null := month_0 + mod(mm_dd_ss.mm, 12);
    -- Check that it's a legal date.
    date_part    constant date        not null := year::text||'-'||month::text||'-'||day_0::text;
    i_model      constant timestamptz not null := (date_part::text||' '||time_part::text)::timestamptz;

    i_actual     constant timestamptz not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Test it thus:

set timezone = 'America/Los_Angeles';
select moment_mm_interval_addition('2021-02-19 12:00:00 America/Los_Angeles'::timestamptz, '1 year 5 months'::interval);

The function finishes without an assert violation—showing that a pure months interval value was supplied and that the semantic model that the function describes holds. This is the result::

 2022-07-19 12:00:00-07

The PL/pgSQL code, by separating out the time-of-day before adding the number of months, vividly makes the point that the semantics of the moment-interval overloads of + and - for pure days interval values honors another flavor of calendar-time-semantics.

Now test it using a timezone that does not respect Daylight Savings time:

set timezone = 'Asia/Shanghai';
select moment_mm_interval_addition('2021-02-19 12:00:00 Asia/Shanghai'::timestamptz, '1 year 5 months'::interval);

Once again, the function finishes without an assert violation—showing that, again, the semantic model that the function describes holds. This is the result:

 2022-07-19 12:00:00+08

Notice that when you do the operation and observe the result in a certain timezone, adding some number of months takes you to the same day of the month and the same time of day that many months later. The PL/pgSQL model does exactly this. Of course, this rule holds whether or not the timezone that your session uses respects Daylight Savings Time"; "spring forward" or "fall back" moments are irrelevant, given the semantic definition. The definition, and the implemented model, make it crystal clear that the number of days between the starting and ending moments for a certain pure months interval value will vary according to the number of days in the various months that the interval happens to span. (The effect of leap years is implied by this statement.)

The overload of the function moment_mm_interval_addition() for a plain timestamp moment is a direct re-write of the overload for the timestamptz moment. Simply replace the text "timestamptz" with the text "timestamp" in the function's source code:

drop function if exists moment_mm_interval_addition(timestamp, interval) cascade;

create function moment_mm_interval_addition(t timestamp, i interval)
  returns timestamp
  language plpgsql
as $body$
declare
  mm_dd_ss constant interval_mm_dd_ss_t := interval_mm_dd_ss(i);
  current_tz text not null := '';
begin
  assert
    mm_dd_ss.ss =  0 and
    mm_dd_ss.dd =  0 and
    mm_dd_ss.mm <> 0,
    'not a pure months interval';
  declare
    time_part    constant text      not null := to_char(t, 'hh24:mi:ss');
    date_part_0  constant date      not null := t::date;
    year_0       constant int       not null := extract(year  from date_part_0);
    month_0      constant int       not null := extract(month from date_part_0);
    day_0        constant int       not null := extract(day   from date_part_0);
    year         constant int       not null := year_0  + trunc(mm_dd_ss.mm/12);
    month        constant int       not null := month_0 + mod(mm_dd_ss.mm, 12);
    -- Check that it's a legal date.
    date_part    constant date      not null := year::text||'-'||month::text||'-'||day_0::text;
    i_model      constant timestamp not null := (date_part::text||' '||time_part::text)::timestamp;

    i_actual     constant timestamp not null := t + i;
  begin
    assert i_model = i_actual, 'assert model does not hold';
    return i_model;
  end;
end;
$body$;

Then test it with trivially modified versions of the tests that were used for the timestamptz overload:

set timezone = 'America/Los_Angeles';
select moment_mm_interval_addition('2021-02-19 12:00:00'::timestamp, '1 year 5 months'::interval);

set timezone = 'Asia/Shanghai';
select moment_mm_interval_addition('2021-02-19 12:00:00'::timestamp, '1 year 5 months'::interval);

You get the same result for each, independently (of course) of what the session's TimeZone setting is:

 2022-07-19 12:00:00