As long as the ⟨parameter text⟩ which the macros have in common is known to you, you can probably do:
\long\def\exchange#1#2{#2#1}%
\long\def\exchangeafterexpand#1#2{%
\romannumeral0%
\expandafter\exchange\expandafter{#1}{\exchange{ }{\expandafter}#2}%
}%
\def\first#1#2thing{first-one: #1 first-two: #2}
\def\second#1#2thing{second-one: #1 second-two: #2}
\expandafter\expandafter\expandafter\exchange
\expandafter\expandafter\expandafter{%
\expandafter\expandafter\expandafter{%
\exchangeafterexpand{\second{#1}{#2}thing}{\first{#1}{#2}thing}%
}%
}{\def\union#1#2thing}%
\show\first
\show\second
\show\union
\bye

Be aware that this does probably not work out with macros defined in terms of LaTeX's \DeclareRobustCommand and also probably does not work out with macros that process optional arguments defined in terms of LaTeX's \newcommand. Reason: Such macros actually are mechanisms where the user-level command performs some test (with \DeclareRobustCommand the definition of the token \protect is tested; with \newcommand the presence of an optional argument is tested) before calling an internal command whose name is derived from the name of the user-level-command.
Also be aware that this approach of expanding with arguments passed in parameter-syntax {#1}/{#2} does only work out as long as the definition texts of \first and/or \second do themselves not not contain definitions of other macros where arguments are denoted by ##1, ##2, i.e., by pairs of hashes. This is because pairs of hashes will be reduced to a single hash during expansion which we don't want.
Therefore if eTeX extensions are available I suggest another route:
\def\union#1#2thing{%
Within the sequence
⟨ Expansion of \first{⟨reserved token⟩1}{⟨reserved token⟩2}thing +
Expansion of \second{⟨reserved token⟩1}{⟨reserved token⟩2}thing
⟩
have every hash doubled and every instance of ⟨reserved token⟩ replaced by a single hash.
}%
}%
This is what I implemented in the example below. This time I used LaTeX. With the example below eTeX-extensions are a requirement for implementing a reliable check for finding out whether a single token is an explicit character token of category code 6 (parameter) / for finding out whether a single token is an explicit hash character token. The gist of that test is: Apply \string to a hash and you get a single explicit character token of category code 12(other) . Apply eTeX's \detokenize to a hash and you get two such tokens because \detokenize doubles hashes.
The example below uses \romannumeral-expansion a lot: The gist of \romannumeral-expansion is that \romannumeral itself triggers a lot of expansion work but does silently not deliver any token in case after all that expansion work it finds a number which is not positive. This \romannumeral-feature is handy because it implies that in many situations a single \expandafter-chain "hitting" \romannumeral is sufficient for triggering several expansion-steps. You only need to ensure that the expansion work results in a token sequence whose leading tokens are, e.g., 0 and [space]. For \romannumeral that sequence will form the number 0 which is not positive and therefore that sequence will silently be discarded while anything behind it in the token-stream will be left in place.
I elaborated on that in my answer to the question "How can I know the number of expandafters when appending to a csname macro?".
\documentclass{article}
\makeatletter
%%=============================================================================
%% Paraphernalia:
%% \UD@firstoftwo, \UD@secondoftwo,
%% \UD@PassFirstToSecond, \UD@Exchange, \UD@removespace
%% \UD@CheckWhetherNull, \UD@CheckWhetherBrace,
%% \UD@CheckWhetherLeadingSpace, \UD@ExtractFirstArg
%%=============================================================================
\newcommand\UD@firstoftwo[2]{#1}%
\newcommand\UD@secondoftwo[2]{#2}%
\newcommand\UD@PassFirstToSecond[2]{#2{#1}}%
\newcommand\UD@Exchange[2]{#2#1}%
\newcommand\UD@removespace{}\UD@firstoftwo{\def\UD@removespace}{} {}%
%%-----------------------------------------------------------------------------
%% Check whether argument is empty:
%%.............................................................................
%% \UD@CheckWhetherNull{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked is empty>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked is not empty>}%
%%
%% The gist of this macro comes from Robert R. Schneck's \ifempty-macro:
%% <https://groups.google.com/forum/#!original/comp.text.tex/kuOEIQIrElc/lUg37FmhA74J>
\newcommand\UD@CheckWhetherNull[1]{%
\romannumeral0\expandafter\UD@secondoftwo\string{\expandafter
\UD@secondoftwo\expandafter{\expandafter{\string#1}\expandafter
\UD@secondoftwo\string}\expandafter\UD@firstoftwo\expandafter{\expandafter
\UD@secondoftwo\string}\expandafter\expandafter\UD@firstoftwo{ }{}%
\UD@secondoftwo}{\expandafter\expandafter\UD@firstoftwo{ }{}\UD@firstoftwo}%
}%
%%-----------------------------------------------------------------------------
%% Check whether argument's first token is a catcode-1-character
%%.............................................................................
%% \UD@CheckWhetherBrace{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked has leading
%% catcode-1-token>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked has no leading
%% catcode-1-token>}%
\newcommand\UD@CheckWhetherBrace[1]{%
\romannumeral0\expandafter\UD@secondoftwo\expandafter{\expandafter{%
\string#1.}\expandafter\UD@firstoftwo\expandafter{\expandafter
\UD@secondoftwo\string}\expandafter\expandafter\UD@firstoftwo{ }{}%
\UD@firstoftwo}{\expandafter\expandafter\UD@firstoftwo{ }{}\UD@secondoftwo}%
}%
%%-----------------------------------------------------------------------------
%% Check whether brace-balanced argument starts with a space-token
%%.............................................................................
%% \UD@CheckWhetherLeadingSpace{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case <argument
%% which is to be checked>'s 1st token is a
%% space-token>}%
%% {<Tokens to be delivered in case <argument
%% which is to be checked>'s 1st token is not
%% a space-token>}%
\newcommand\UD@CheckWhetherLeadingSpace[1]{%
\romannumeral0\UD@CheckWhetherNull{#1}%
{\expandafter\expandafter\UD@firstoftwo{ }{}\UD@secondoftwo}%
{\expandafter\UD@secondoftwo\string{\UD@CheckWhetherLeadingSpaceB.#1 }{}}%
}%
\newcommand\UD@CheckWhetherLeadingSpaceB{}%
\long\def\UD@CheckWhetherLeadingSpaceB#1 {%
\expandafter\UD@CheckWhetherNull\expandafter{\UD@secondoftwo#1{}}%
{\UD@Exchange{\UD@firstoftwo}}{\UD@Exchange{\UD@secondoftwo}}%
{\UD@Exchange{ }{\expandafter\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter}\expandafter\expandafter
\expandafter}\expandafter\UD@secondoftwo\expandafter{\string}%
}%
%%-----------------------------------------------------------------------------
%% Check whether argument contains no exclamation mark which is not nested
%% in braces:
%%.............................................................................
%% \UD@CheckWhetherNoExclam{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case that argument
%% contains no exclamation mark>}%
%% {<Tokens to be delivered in case that argument
%% contains exclamation mark>}%
%%
\newcommand\UD@GobbleToExclam{}\long\def\UD@GobbleToExclam#1!{}%
\newcommand\UD@CheckWhetherNoExclam[1]{%
\expandafter\UD@CheckWhetherNull\expandafter{\UD@GobbleToExclam#1!}%
}%
%%-----------------------------------------------------------------------------
%% \Parameterchar@reservedFork grabs the first thing behind a
%% a token-sequence of pattern !!\Parameterchar@reserved!
%%.............................................................................
\newcommand\Parameterchar@reservedFork{}
\long\def\Parameterchar@reservedFork#1!!\Parameterchar@reserved!#2#3!!!!{#2}%
%%-----------------------------------------------------------------------------
%% Check whether argument consists only of the token \Parameterchar@reserved
%%.............................................................................
\newcommand\UD@CheckWhetherParameterchar@reserved[1]{%
\romannumeral0%
\UD@CheckWhetherNoExclam{#1}{%
\Parameterchar@reservedFork
%Case #1 is empty/has no tokens:
!#1!\Parameterchar@reserved!{\UD@Exchange{ }{\expandafter}\UD@secondoftwo}%
%Case #1 = \Parameterchar@reserved:
!!#1!{\UD@Exchange{ }{\expandafter}\UD@firstoftwo}%
%Case #1 = something else without exclamation-mark:
!!\Parameterchar@reserved!{\UD@Exchange{ }{\expandafter}\UD@secondoftwo}%
!!!!%
}{%
%Case #1 = something else with exclamation-mark:
\UD@Exchange{ }{\expandafter}\UD@secondoftwo
}%
}%
%%-----------------------------------------------------------------------------
%% Extract first inner undelimited argument:
%%
%% \UD@ExtractFirstArg{ABCDE} yields {A}
%%
%% \UD@ExtractFirstArg{{AB}CDE} yields {AB}
%%.............................................................................
\newcommand\UD@RemoveTillUD@SelDOm{}%
\long\def\UD@RemoveTillUD@SelDOm#1#2\UD@SelDOm{{#1}}%
\newcommand\UD@ExtractFirstArg[1]{%
\romannumeral0%
\UD@ExtractFirstArgLoop{#1\UD@SelDOm}%
}%
\newcommand\UD@ExtractFirstArgLoop[1]{%
\expandafter\UD@CheckWhetherNull\expandafter{\UD@firstoftwo{}#1}%
{ #1}%
{\expandafter\UD@ExtractFirstArgLoop\expandafter{\UD@RemoveTillUD@SelDOm#1}}%
}%
%%=============================================================================
%% \DoubleEveryHashAndReplaceParameterchar@reserved{<argument>}%
%%
%% Each explicit catcode-6(parameter)-character-token of the <argument>
%% will be doubled. Each instance of \Parameterchar@reserved will be replaced
%% by a single hash.
%%
%% You obtain the result after two expansion-steps, i.e.,
%% in expansion-contexts you get the result after "hitting"
%% \DoubleEveryHashAndReplaceParameterchar@reserved by two \expandafter.
%%
%% As a side-effect, the routine does replace matching pairs of explicit
%% character tokens of catcode 1 and 2 by matching pairs of curly braces
%% of catcode 1 and 2.
%% I suppose this won't be a problem in most situations as usually the
%% curly braces are the only characters of category code 1 / 2...
%%
%% This routine needs \detokenize from the eTeX extensions.
%%-----------------------------------------------------------------------------
\newcommand\DoubleEveryHashAndReplaceParameterchar@reserved[1]{%
\romannumeral0\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop{#1}{}%
}%
\newcommand\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop[2]{%
\UD@CheckWhetherNull{#1}{ #2}{%
\UD@CheckWhetherLeadingSpace{#1}{%
\expandafter\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop
\expandafter{\UD@removespace#1}{#2 }%
}{%
\UD@CheckWhetherBrace{#1}{%
\expandafter\expandafter\expandafter\UD@PassFirstToSecond
\expandafter\expandafter\expandafter{%
\expandafter\UD@PassFirstToSecond\expandafter{%
\romannumeral0%
\expandafter\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop
\romannumeral0%
\UD@ExtractFirstArgLoop{#1\UD@SelDOm}{}%
}{#2}}%
{\expandafter\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop
\expandafter{\UD@firstoftwo{}#1}}%
}{%
\expandafter\UD@CheckWhetherHash
\romannumeral0\UD@ExtractFirstArgLoop{#1\UD@SelDOm}{#1}{#2}%
}%
}%
}%
}%
\newcommand\UD@CheckWhetherHash[3]{%
\expandafter\UD@CheckWhetherLeadingSpace\expandafter{\string#1}{%
\expandafter\expandafter\expandafter\UD@CheckWhetherNull
\expandafter\expandafter\expandafter{%
\expandafter\UD@removespace\string#1}{%
\expandafter\expandafter\expandafter\UD@CheckWhetherNull
\expandafter\expandafter\expandafter{%
\expandafter\UD@removespace\detokenize{#1}}{%
% Something whose stringification yields a single space
\UD@secondoftwo
}{% Explicit space of catcode 6
\UD@firstoftwo
}%
}{% Something whose stringification has a leading space
\UD@secondoftwo
}%
}{%
\expandafter\expandafter\expandafter\UD@CheckWhetherNull
\expandafter\expandafter\expandafter{%
\expandafter\UD@firstoftwo
\expandafter{\expandafter}\string#1}{%
\expandafter\expandafter\expandafter\UD@CheckWhetherNull
\expandafter\expandafter\expandafter{%
\expandafter\UD@firstoftwo
\expandafter{\expandafter}\detokenize{#1}}{%
% no hash
\UD@secondoftwo
}{% hash
\UD@firstoftwo
}%
}{% no hash
\UD@secondoftwo
}%
}%
{% hash
\expandafter\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop
\expandafter{\UD@firstoftwo{}#2}{#3#1#1}%
}{% no hash
\UD@CheckWhetherParameterchar@reserved{#1}{%
\expandafter\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop
\expandafter{\UD@firstoftwo{}#2}{#3##}%
}{%
\expandafter\UD@DoubleEveryHashAndReplaceParameterchar@reservedLoop
\expandafter{\UD@firstoftwo{}#2}{#3#1}%
}%
}%
}%
%%=============================================================================
\newcommand\UD@TripleExpandAndConcatSecondAndThird[3]{%
\romannumeral0\expandafter\UD@PassFirstToSecond\expandafter{%
\romannumeral0%
\expandafter\UD@Exchange\expandafter{%
\romannumeral0%
\UD@Exchange{ }{\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter\expandafter}%
#3%
}{%
\UD@Exchange{ }{\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter\expandafter}#2%
}%
}{ #1}%
}%
%%=============================================================================
\def\first#1#2thing{first-one: #1 first-two: #2 \def\firsttest##1{##1}}
\def\second#1#2thing{second-one: #1 second-two: #2 \def\secondtest##1{##1}}
\UD@TripleExpandAndConcatSecondAndThird{%
\def\union#1#2thing%
}{%
\expandafter\DoubleEveryHashAndReplaceParameterchar@reserved
\expandafter{\first{\Parameterchar@reserved1}{\Parameterchar@reserved2}thing}%
}{%
\expandafter\DoubleEveryHashAndReplaceParameterchar@reserved
\expandafter{\second{\Parameterchar@reserved1}{\Parameterchar@reserved2}thing}%
}%
\show\first
\show\second
\show\union
\stop

Of course this does work out only with macros whose ⟨definition text⟩ does not contain the token \Parameterchar@reserved and where replacing arbitrary explicit character tokens of category code 1 (begin group) and matching arbitrary explicit character tokens of category code 2 (end group) by explicit left-curly-brace-character tokens of category code 1 (begin group) and matching explicit right-curly-brace-character tokens of category code 2 (end group) does not matter.
\apptocmdcommand frometoolbox. – Phelype Oleinik Sep 04 '19 at 19:03\meaning\macrobut that does not provide sufficient information on category codes. Besides this with meaning you, e.g., cannot reliably decide whether a phrase\@name<space>is due to a control word token\@nameor due to a token sequence control symbol token\@,n,a,m,e, space token. – Ulrich Diez Sep 04 '19 at 21:57\apptocmdlooks interesting! – Robert Siemer Sep 05 '19 at 00:02