TypeScript: convert tuple type to object
Asked Answered
P

3

6

Summary: I have a tuple type like this:

[session: SessionAgent, streamID: string, isScreenShare: boolean, connectionID: string, videoProducerOptions: ProducerOptions | null, connection: AbstractConnectionAgent, appData: string]

and I want to convert it to an object type like this:

type StreamAgentParameters = {
  session: SessionAgent
  streamID: string
  isScreenShare: boolean
  connectionID: string
  videoProducerOptions: ProducerOptions | null
  connection: AbstractConnectionAgent
  appData: string
}

Is there a way to do that?


I want to create a factory function for tests for a class to simplify the setup.

export type Factory<Shape> = (state?: Partial<Shape>) => Shape

I want to avoid manually typing out the parameters for the class, so I looked for possibilities to get the parameters for the constructor. And what do you know, there is the ConstructorParameters helper type. Unfortunately, it returns a tuple instead of an object.

Therefore the following doesn't work because a tuple is NOT an object.

type MyClassParameters = ConstructorParameters<typeof MyClass>
// ↵ [session: SessionAgent, streamID: string, isScreenShare: boolean, connectionID: string, videoProducerOptions: ProducerOptions | null, connection: AbstractConnectionAgent, appData: string]

const createMyClassParameters: Factory<MyClassParameters> = ({
  session = new SessionAgent(randomRealisticSessionID()),
  streamID = randomRealisticStreamID(),
  isScreenShare = false,
  connectionID = randomRealisticConnectionID(),
  videoProducerOptions = createPopulatedProducerOptions(),
  connection = new ConnectionAgent(
    new MockWebSocketConnection(),
    'IP',
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ),
  appData = 'test',
} = {}) => ({
  session,
  streamID,
  isScreenShare,
  connectionID,
  videoProducerOptions,
  connection,
  appData,
})

I tried creating a helper type that converts a tuple to an object, but my best attempt was this (and it didn't work).

type TupleToObject<T extends any[]> = {
  [key in T[0]]: Extract<T, [key, any]>[1]
}

How can I solve this problem?

Plated answered 7/9, 2021 at 9:16 Comment(0)
W
7

As mentioned in the other answers, there's no way to convert tuple labels into string literal types; the labels are just for documentation and don't affect the type system: the types [foo: string] and [bar: string] and [string] are all equivalent to each other. So any method to turn [foo: string] into {foo: string} should also turn [bar: string] into {foo: string}. So we need to give up on capturing tuple labels.


The real keys of a tuple are numeric strings like "0" and 1". If you just want to turn a tuple into a similar type with just those numeric-like keys and not all the array properties and methods, you can do it like this:

type TupleToObject<T extends any[]> = Omit<T, keyof any[]>

This just uses the Omit<T, K> utility type to ignore any tuple properties that exist in all arrays (like length, push, etc). This is also more or less equivalent to

type TupleToObject<T extends any[]> = 
  { [K in keyof T as Exclude<K, keyof any[]>]: T[K] }

which uses a mapped type with filtered out keys explicitly.

Here's how it behaves on your tuple type:

type StreamAgentObjectWithNumericlikeKeys = TupleToObject<StreamAgentParameters>
/* type StreamAgentObjectWithNumericlikeKeys = {
    0: SessionAgent;
    1: string;
    2: boolean;
    3: string;
    4: ProducerOptions | null;
    5: AbstractConnectionAgent;
    6: string;
} */

You could also make a function to do the same thing to actual values:

const tupleToObject = <T extends any[]>(
  t: [...T]) => ({ ...t } as { [K in keyof T as Exclude<K, keyof any[]>]: T[K] });
const obj = tupleToObject(["a", 2, true]);
/* const obj: {
    0: string;
    1: number;
    2: boolean;
} */
console.log(obj) // {0: "a", 1: 2, 2: true};

If you are willing to hold onto an tuple of property names in addition to your tuple of types, you can write a function which maps the numeric tuple keys to the corresponding name:

type TupleToObjectWithPropNames<
  T extends any[],
  N extends Record<keyof TupleToObject<T>, PropertyKey>
  > =
  { [K in keyof TupleToObject<T> as N[K]]: T[K] };
   
type StreamAgentParameterNames = [
  "session", "streamID", "isScreenShare", "connectionID",
  "videoProducerOptions", "connection", "appData"
];

type StreamAgentObject =
  TupleToObjectWithPropNames<StreamAgentParameters, StreamAgentParameterNames>
/* 
type StreamAgentObject = {
  session: SessionAgent
  streamID: string
  isScreenShare: boolean
  connectionID: string
  videoProducerOptions: ProducerOptions | null
  connection: AbstractConnectionAgent
  appData: string
}
*/

And you can make a function to do the same to actual values:

const tupleToObjectWithPropNames = <T extends any[],
  N extends PropertyKey[] & Record<keyof TupleToObject<T>, PropertyKey>>(
    tuple: [...T], names: [...N]
  ) => Object.fromEntries(Array.from(tuple.entries()).map(([k, v]) => [(names as any)[k], v])) as
  { [K in keyof TupleToObject<T> as N[K]]: T[K] };

const objWithPropNames = tupleToObjectWithPropNames(["a", 2, true], ["str", "num", "boo"])
/* const objWithPropNames: {
    str: string;
    num: number;
    boo: boolean;
} */
console.log(objWithPropNames); // {str: "a", num: 2, boo: true}

Playground link to code

Waxy answered 17/12, 2021 at 20:11 Comment(1)
Clever solution, as always!Mahaliamahan
M
5

In order to convert any tuple to object, you can use this utility type:

type Reducer<
  Arr extends Array<unknown>,
  Result extends Record<number, unknown> = {},
  Index extends number[] = []
  > =
  Arr extends []
  ? Result
  : Arr extends [infer Head, ...infer Tail]
  ? Reducer<[...Tail], Result & Record<Index['length'], Head>, [...Index, 1]>
  : Readonly<Result>;

// Record<0, "hi"> & Record<1, "hello"> & Record<2, "привіт">
type Result = Reducer<['hi', 'hello', 'привіт']>;

Since we are converting from the tuple you are able to use only elements indexes as a key.

In order to keep information about the key/index I have added extra Index generic type to type utility. Every iteration I'm adding 1 and compute new length of indexI

You are not allowed to use tuple labels as a key since:

They’re purely there for documentation and tooling.

Mahaliamahan answered 11/9, 2021 at 13:52 Comment(5)
Is there a benefit to this solution over this one: type TupleToObject<T extends any[]> = Omit<T, keyof any[]>?Waxy
@Waxy I do think that your solution is better.Mahaliamahan
So do you want me to post a separate answer or do you want to edit this one?Waxy
@Waxy please post as a separate answerMahaliamahan
All right, here. It's another long rant as usual.Waxy
M
0

TL;DR: It’s impossible to convert a tuple type into an object, since information about the key is missing from the tuple.

When you say you have a tuple type like [session: SessionAgent, streamID: string], I guess you really mean [SessionAgent, string].

You don’t get to keep the variable names along side the tuple, they’re discarded, and there’s no way to restore lost information.

A workaround, if it suits you, would be converting MyClass constructor signature from positional params to named params.

// from:
class MyClass {
  constructor(session: SessionAgent, streamID: string) {…}
}

// to:
class MyClass {
  constructor(opt: { session: SessionAgent, streamID: string }) {…}
}

// now you can infer:
type MyClassParameters = ConstructorParameters<typeof MyClass>[0]
// ↵ { session: SessionAgent, streamID: string }
Mussman answered 9/9, 2021 at 17:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.