TypeScript: Позиционные ИЛИ Именованные параметры в конструкторе

У меня есть класс, который в настоящее время принимает более 7 позиционных параметров.

class User {
  constructor (param1, param2, param3, …etc) {
    // …
  }
}

Я хочу преобразовать это в именованные параметры через объект параметров.

type UserOptions = {
  param1: string
  // …
}

class User {
  constructor ({ param1, param2, param3, …etc } = {}: UserOptions) {
    // …
  }
}

Это нормально, за исключением того, что теперь необходимо переработать множество тестов, чтобы изменить подпись, поэтому я хотел бы поддерживать как именованные, так и позиционные параметры.

Я могу сделать так, чтобы код поддерживал оба типа, но я не уверен, как добавить туда типы, не выписывая все типы дважды. В идеале список типов в моем UserOptions стал бы позиционными параметрами в том порядке, в котором они определены в UserOptions.

Есть ли способ сделать что-то подобное?

type UserOptions = {
  param1: string
  // …
}

type ToPositionalParameters<T> = [
  // ??? something like ...Object.values(T)
  // and somehow getting the keys as the positional argument names???
]

type PositionalUserOptions = ToPositionalParameters<UserOptions>

class User {
  constructor (...args: PositionalUserOptions);
  constructor (options: UserOptions);
  constructor (...args: [UserOptions] | PositionalUserOptions) { 
    // …
  }
}

Можно также рассмотреть решение, работающее наоборот, то есть позиционные аргументы для named, возможно, это проще?

Всего 2 ответа


Есть несколько вещей, которые вам мешают.

Во-первых, порядок свойств в типе объекта в настоящее время ненаблюдаемый в TypeScript, потому что они не влияют на возможность назначения. Например, в системе типов нет разницы между типом {a: string, b: number} и {b: number, a: string}. Есть dirty tricks, который вы можете выполнить, чтобы извлечь информацию из компилятора, чтобы увидеть, представляет ли он ключи как ["a", "b"] против ["b", "a"], но все, что это делает, это дает вам некоторый порядок при компиляции ; при чтении объявления типа сверху вниз не гарантируется, что он будет упорядочен ... черт возьми, это даже не гарантирует одинакового упорядочивания при каждой компиляции! См. microsoft/TypeScript#17944, где есть открытый вопрос, требующий согласованного упорядочивания. И см. microsoft/TypeScript#42178 для примера, казалось бы, несущественного изменения кода, которое изменяет порядок от ожидаемого. На данный момент мы не можем каким-либо согласованным образом автоматически преобразовать тип объекта в упорядоченный кортеж.

Во-вторых, имена аргументов в типе функции намеренно недоступны как string literal types. Они ненаблюдаемы по тем же причинам, что и упорядочение свойств объекта: они не влияют на возможность назначения. Например, нет разницы между типом функции (foo: string) => void и (bar: string) => void в системе типов. Я даже не думаю, что есть какие-то уловки, которые могли бы раскрыть такие детали. В системе типов имена аргументов функций используются только в качестве документации или IntelliSense. Вы можете преобразовать аргументы именованной функции в labeled tuple elements, но не более того. См. this comment in microsoft/TypeScript#28259, объясняющий, что мы не можем использовать имена параметров сигнатуры вызовов или метки кортежей в качестве строк в TypeScript. На данный момент мы не можем автоматически превратить помеченный кортеж в тип объекта, где ключи объекта соответствуют меткам кортежа.


Вы можете как бы обойти обе эти проблемы, если вместо попытки преобразовать объект в кортеж или кортеж в объект, мы предоставим достаточно информации, чтобы сделать и то, и другое:

const userOptionKeys = ["param1", "param2", "thirdOne"] as const;
type PositionalUserOptions = [string, number, boolean];

Здесь userOptionKeys - это явно упорядоченный список желаемых ключей в объектах UserOptions ... в том же порядке, что и в нашем созданном вручную кортеже PositionalUserOptions. Теперь, когда у нас есть имена ключей, мы можем создать UserOptions:

type UserOptions = { [I in Exclude<keyof PositionalUserOptions, keyof any[]> as
  typeof userOptionKeys[I]]: PositionalUserOptions[I] }
/* type UserOptions = {
    param1: string;
    param2: number;
    thirdOne: boolean;
} */

И пока мы находимся здесь, мы можем написать функцию для преобразования кортежа типа PositionalUserOptions в объект типа UserOptions (с утверждением типа any, чтобы освободить компилятор от необходимости пытаться проверить это, что сделать нелегко):

function positionalToObj(opts: PositionalUserOptions): UserOptions {
  return opts.reduce((acc, v, i) => (acc[userOptionKeys[i]] = v, acc), {} as any)
}

Теперь мы можем написать этот класс User, используя positionalToObj в реализации конструктора для нормализации вещей:

class User {
  constructor(...args: PositionalUserOptions);
  constructor(options: UserOptions);
  constructor(...args: [UserOptions] | PositionalUserOptions) {
    const opts = args.length === 1 ? args[0] : positionalToObj(args);
    console.log(opts);
  }
}

new User("a", 1, true);
/* {
  "param1": "a",
  "param2": 1,
  "thirdOne": true
}  */

Работает! С точки зрения системы типов и присваиваемости это лучшее, что вы можете сделать. С точки зрения документации / IntelliSense это не очень хорошо. Когда вы вызываете new User(), документация для многопараметрической версии даст вам такие ярлыки, как args_0 и args_1. Если вы хотите, чтобы это были param1 и param2, вам просто нужно укусить пулю и дважды написать имена параметров; один раз в виде строковых литералов и еще раз в виде меток кортежей, поскольку нет возможности преобразовать один в другой:

const userOptionKeys = ["param1", "param2", "thirdOne"] as const;
type PositionalUserOptions = [param1: string, param2: number, thirdOne: boolean];

Оно того стоит? Может ... решать тебе.

Playground link to code


Вы можете сделать это, но инструменты, которые вы должны использовать, не одобряются командой TypeScript и полагаются на порядок создания экземпляров определенных внутренних типов (по крайней мере, это то, что предлагает the issue asking for a union-to-tuple operation).

Тем не менее, было весело разобраться в этом, так что готово:

// Via https://github.com/Microsoft/TypeScript/issues/13298#issuecomment-707364842
type UnionToTuple<T> = (
    (
        (
            T extends any
                ? (t: T) => T
                : never
        ) extends infer U
            ? (U extends any
                ? (u: U) => any
                : never
            ) extends (v: infer V) => any
                ? V
                : never
            : never
    ) extends (_: any) => infer W
        ? [...UnionToTuple<Exclude<T, W>>, W]
        : []
);

type Head<T> = T extends [infer A, ...any] ? A : never
type Tail<T extends any[]> = T extends [any, ...infer A] ? A : never;

type PluckFieldTypes<T extends object, Fields extends any[] =
  UnionToTuple<keyof T>> = _PluckFieldType<T, Head<Fields>, Tail<Fields>, []>
type _PluckFieldType<
  T extends object,
  CurrentKey,
  RemainingKeys extends any[],
  Result extends any[]
  > = RemainingKeys['length'] extends 0
      ? CurrentKey extends keyof T ? [...Result, T[CurrentKey]] : never
      : CurrentKey extends keyof T
        /* && */? RemainingKeys extends (keyof T)[]
        ? [...Result, T[CurrentKey], ..._PluckFieldType<T, Head<RemainingKeys>, Tail<RemainingKeys>, []>]
        : never : never;

// -- IMPL --
type Args = {
  param1: string,
  param2: number,
  param3: Date,
  param4: number,
  param5: string,
  param6: Date,
  param7: boolean,
  param8: boolean,
  param9: null,
  param10: 'abc' | 'xyz'
}

class CompositeClass {
  constructor(params: Args);
  constructor(param1: string, param2: number, param3: Date);
  constructor(...args: [Args] | PluckFieldTypes<Args>) {
  }
}

Предполагаемый тип ...args оказывается (parameter) args: [Args] | [string, number, Date, number, string, Date, boolean, boolean, null, "abc" | "xyz"], а второй конструктор получает красную волнистую линию под ним, пока вы не добавите все остальные параметры.

Единственное, что мы не можем получить, - это требование, чтобы имена аргументов соответствовали именам полей (но это потому, что помеченные кортежи не делают их метки доступными на уровне типа, поэтому вы не можете их прочитать из встроенного ConstructorArguments или запишите их здесь с помощью _PluckFieldType.)


Есть идеи?

10000