1

Is there a way to map a type like the following:

type ObjectType = {
  prop1: number;
  prop2: string;
  prop3: boolean;
  prop4: string;
};

to the following?

type TupleType = [number, string, boolean, string]

I had no luck first converting an object of type ObjectType to an array with Object.values. The type of the resulting array would evaluate to (string | number | boolean)[]. That's not a tuple type, just an array of a union type.

From the comments I learned, if the object would be modified after initially defining it, the order of the properties could change. For that case I would also be able to use a Map.

Another possible solution could use an Array of Tuples like the following as a base, to ensure that the order does not change:

type TupleListType = [
  [string, number],
  [string, string],
  [string, boolean],
  [string, string],
];
const argsTupleList: TupleListType = [
  ["prop1", 1],
  ["prop2", "two"],
  ["prop3", true],
  ["prop4", "four"],
]

If I'd have a way to convert argsTupleList to an array of type [number, string, boolean, string], it would be solved, too.


The resulting array needs to have that type, so I can pass it to a function with the spread operator, like someFunc(...argsTuple). Have a look at this example in the TypeScript playground.

If I just convert the object to an array with Object.values(args) I get:

A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)

Refactoring the function to accept a different format of args is not what I am looking for.

Wu Wei
  • 1,827
  • 1
  • 15
  • 27
  • 1
    Can you provide a minimal reproducible example which shows the error you're trying to address in the context of code. I don't see any use of the spread operator in your example. – cefn Feb 28 '23 at 10:24
  • 1
    Objects aren't semantically ordered (they're string keys, so they'll be in _insertion_ order), and the type certainly isn't. But a simple `function extract(obj: ObjectType): [/* tuple */]` could type- and runtime-safely give you something that spreads into positional arguments, rather than just telling the compiler to let you spread the values however they come and hoping for the best. – jonrsharpe Feb 28 '23 at 10:48
  • 1
    Might have been because I was following the wrong track on Object.keys() rather than Object.values() which was my oversight from rushing. – cefn Feb 28 '23 at 11:03
  • @jonrsharpe Very good point. I would also be fine to start out with a Map instead of an object: `new Map(Object.entries({prop1: 1, prop2: "two", prop3: 3})`. – Wu Wei Feb 28 '23 at 11:11
  • 1
    Does [this approach](https://tsplay.dev/wgD11N) work for your "another possible solution" part? If so I can write up an answer explaining; if not, what am I missing? – jcalz Feb 28 '23 at 14:36
  • @jcalz Yes, exactly, brilliant. An explanation would be very much needed! – Wu Wei Feb 28 '23 at 14:50

2 Answers2

1

Based on the minimal repro shared, I wrote up a function which just transforms the structure in a type safe way.

type ObjectType = {
  prop1: number;
  prop2: string;
  prop3: boolean;
  prop4: string;
};

const args: ObjectType = {
  prop1: 1,
  prop2: "two",
  prop3: true,
  prop4: "four",
};

function someFunc(arg1: number, arg2: string, arg3: boolean, arg4: string) {
  console.log(arg1, arg2, arg3, arg4);
}

function composeParameters(obj: ObjectType): Parameters<typeof someFunc> {
  const { prop1, prop2, prop3, prop4 } = obj;
  return [prop1, prop2, prop3, prop4];
}

someFunc(...composeParameters(args));

Playground

jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
cefn
  • 2,895
  • 19
  • 28
  • It has some interesting parts, but it only works for the specific object. The composeParameters function would have to work for other objects as well. I would also be fine to use a Map instead of an object, btw. – Wu Wei Feb 28 '23 at 11:07
  • 1
    @anarchist912 _what_ other objects? It's not safe (at compile time or at runtime) to just assume that the object's values will come out in a particular order, as it will depend how they were inserted, so to safely spread them into a function you need _something_ to ensure they end up in the right places. `new Map(Object.entries(thing))` doesn't help at runtime, because the map will just retain the same (unknown!) insertion order you're starting with, or at compile time, because it would just be `Map`. – jonrsharpe Feb 28 '23 at 11:16
  • @jonrsharpe I edited my question to ask for a solution to map an array of tuples to a tuple. – Wu Wei Feb 28 '23 at 12:40
  • @anarchist912 ...why? Is that _actually_ what you're starting with? It seems increasingly likely that this is an [XY problem](https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem). If you have an object and want to get the values out in the right order to pass to a function, this _is_ the right answer. – jonrsharpe Feb 28 '23 at 13:08
  • yes, it can be what I am starting with. What I am trying to achieve is for writing tests. I want less repetition when passing arguments to a function that takes 12 positional args, and has to be tested multiple times. So I have full control over the initial object – whatever structure it has – but not over the function to be tested. The answer does indeed solve this, but only for one particular use case. I have multiple functions with a lot of args to be passed that need to be tested, so a more generic solution would be nice. @jonrsharpe – Wu Wei Feb 28 '23 at 13:36
  • @anarchist912 leaving aside the smell of _"a function that takes 12 positional args"_, why don't you just _start_ with the tuple you want? If you're writing _parameterised tests_, iterate over `Parameters[]`. – jonrsharpe Feb 28 '23 at 14:27
0

To avoid the issues of trying to get the TypeScript compiler to know or care about property order in object types (which are many; keyof will produce a union, whose order is implementation-defined, and so you can get something out of that but it's likely to be different from what you expect; see How to transform union type to tuple type for more information) we will explore your "another possible solution" which uses a tuple-of-tuples to start with instead of an object type.


We need a function like argsTupleListToTupleType() which takes an input whose type is an array of key-value pairs (each of which have a keylike type for its first element) and converts it into an array of just values. We can express this in the type system as

declare function argsTupleListToTupleType<T extends any[]>(
  argsTupleList: readonly [...{ [I in keyof T]: readonly [PropertyKey, T[I]] }]
): T;

This function is generic in the output type T, and the argsTupleList input argument is of a mapped tuple type on T, where each element of T has been replaced with a readonly pair with a keylike first member and the element from T as the second member. (Note that readonly tuples accept more inputs than non-readonly tuples, so this is more accommodating.) Since this is a homomorphic mapped type (see What does "homomorphic mapped type" mean? ) the compiler is able to infer T from the input value of the mapped type.

Also note that I made the type of argsTupleList a readonly variadic tuple type, so readonly [...XXX] instead of just XXX, which gives the compiler a hint that it should interpret argsTupleList as a tuple type where possible, and that it should accept any const-asserted inputs. Essentially this wrapping just makes the function more accommodating of being called in different ways, without really changing the basic operation.


So that's the type system, what about implementation? Well that's a straightforward argsTupleList.map(x => x[1]) using the map() array method. The TypeScript compiler can't possibly verify that this satisfies the call signature, so we'll need to use a type assertion to tell it that we've done the checking ourselves and that it shouldn't worry. (See Mapping tuple-typed value to different tuple-typed value without casts for more information):

function argsTupleListToTupleType<T extends any[]>(
  argsTupleList: readonly [...{ [I in keyof T]: readonly [PropertyKey, T[I]] }]
): T {
  return argsTupleList.map(x => x[1]) as T;
}

Let's try it out with your example:

type TupleListType = [
  [string, number],
  [string, string],
  [string, boolean],
  [string, string],
];
const argsTupleList: TupleListType = [
  ["prop1", 1],
  ["prop2", "two"],
  ["prop3", true],
  ["prop4", "four"],
]

const tuple = argsTupleListToTupleType(argsTupleList);
// const tuple: [number, string, boolean, string]
console.log(tuple); // [1, "two", true, "four"] 

Looks good. Both the type and value of tuple are what we expect. This also works with different inputs:

const t2 = argsTupleListToTupleType([
  ["a", 1], ["b", true], ["c", new Date()]
]);
// const t2: [number, boolean, Date]
console.log(t2); // [1, true, Date: "2023-02-28T16:47:56.073Z"] 

const inp = [["x", 0], ["y", null], ["z", undefined]] as const;
const t3 = argsTupleListToTupleType(inp); // const t3: [0, null, undefined]
console.log(t3); // [0, null, undefined] 

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360