Нам надо чтобы юнион целиком брался, а не каждый тип из юниона отдельно сравнивался.
type UnionToIntersection<U> =
[(U extends unknown ? (k: U) => unknown : never)] extends [((k: infer I) => unknown)] ? I : never;
А ещё через функции можно достать последний тип из юниона за счёт эффекта перегрузки функции.)
type ExtractObjectType<T> = T extends (infer U) & unknown ? (U extends object ? U : never) : never;
type ExtractObjectType1<T> = T extends object ? T : never;
type UnionToIntersection<U> =
(U extends unknown ? (k: U) => unknown : never) extends ((k: infer I) => unknown) ? I : never;
type A = {a: string};
type B = {b: string};
type T1 = UnionToIntersection<A | B>; // A & B
type UnionToIntersectionPart1<U> = (U extends unknown ? (k: U) => void : never);
type UnionToIntersectionPart2<T> = T extends ((k: infer I) => void) ? I : never;
type T2 = UnionToIntersectionPart1<A | B>;
type T3 = UnionToIntersectionPart2<T2>; // A | B
type UnionToIntersectionPart2a<T> = [T] extends [((k: infer I) => void)] ? I : never;
type T3a = UnionToIntersectionPart2a<T2>; // A & B
Ну если у тебя хвостовая рекурсия, то стек не нужен, просто цикл. Иначе без стека никак.
Имеется в виду, что стек как структура данных, например обычный массив с методами push и pop, а не тот, который использует исполняющая среда