1

Is there a way of mapping the type of each field of an object to another type with TypeScript?

More specifically, I'm trying to build a function that takes in some sort of type definition, runs a different function depending on the type of each field, and puts the result back into an object under the same name:

// Input to function
registerData({
    bodyText: 'string',
    isAdmin: 'boolean',
    postCount: 'number'
});

// The function returns this:
{
    bodyText: 'Lorem ipsum dolor sit amet',
    isAdmin: true,
    postCount: 20
}

// So the desired type would be:
{
    bodyText: string,
    isAdmin: bool,
    postCount: number
}

Is there any way to get the correct type for this return value?

I'm not tied to this specific input format, but it would be useful to have some method of differentiating data of the same type (e.g. being able to define a separate function for int and float that both end up as a number[]).

It would be useful to be able to have any arbitrary types as output fields, as I may need to pass out functions or arrays instead of just primitive values.

I've already tried some trickery with infer, but it combines all of the types of all fields:

type DataType = 'string' | 'number' | 'boolean';
type DataDefinition = { [name: string]: DataType }
type DataReturn<T> = T extends { [K in keyof T]: infer Item } ? { [K in keyof T]: DataReturnItem<Item> } : never;
type DataReturnItem<T> = 
      T extends 'string' ? string
    : T extends 'number' ? number
    : T extends 'boolean' ? boolean
    : never;

const registerData = <T extends DataDefinition>(data: T) => {
    // Generate data based on field types
    return data as DataReturn<T>;
}

If I call registerData with the example mentioned above, the type it produces is:

{
    bodyText: string | bool | number,
    isAdmin: string | bool | number,
    postCount: string | bool | number
}

This solution is also really awkward, as it requires me to tack on another case to the extends statements for DataReturnItem<T>, which is going to get ugly fairly quickly.

2
  • 1
    Does this approach meet your needs? If so I'll write up an answer explaining; if not, what am I missing? (Note: "It would be useful to be able to have any arbitrary types as output fields, as I may need to pass out functions or arrays instead of just primitive values." is probably out of scope unless you come up with some way of encoding these types; I doubt SO is a great place to get others to build a type schema system for you; there are undoubtedly libraries that do such things) Commented Aug 4, 2023 at 18:04
  • Yes, that looks like it could work for me. Is there any way that I could pass in some kind of object such as { output: string, data: 'formatted-date' }, or { output: number, data: 'integer' } to be able to pass the relevant types, so that I can specify different functions to run to produce the same types? For arbitrary output types I can probably make do with the same type transformation for all types (so string becomes () => string, number becomes () => number and so on), if that makes it any easier. Commented Aug 7, 2023 at 9:55

2 Answers 2

1

You'll need to come up with some sort of mapping from string literal types used for property values in the argument to registerData() to the property value types you want to see in the output of registerData(). Luckily TypeScript has an easy way to represent a mapping from string literal types to arbitrary types: an interface:

interface DataReturn {
  string: string;
  boolean: boolean;
  number: number;
  // add entries here if you need it
}

Then you can give registerData() a generic call signature which like this:

declare function registerData<T extends Record<keyof T, keyof DataReturn>>(
  data: T
): { [K in keyof T]: DataReturn[T[K]] }

Here data is of type T constrained to have property values matching the keys of the DataReturn interface. It's a recursive constraint (aka F-bounded constraint) T extends Record<keyof T, keyof DataReturn>, using the Record<K, V> utility type to say that T must have keys of type keyof T (this is impossible not to happen) and values of type keyof DataReturn (which is the constraint we're trying to enforce). (You could say something like T extends Record<string, keyof DataReturn> but that would possibly reject some inputs, see Typescript: Why type alias satisfies a constraint but same interface doesn't? for more information.)

And the output type is a mapped type where the keys are unchanged but the values are transformed from the keys of DataReturn to their corresponding values.


Let's test it out:

const x = registerData({
  bodyText: 'string',
  isAdmin: 'boolean',
  postCount: 'number'
});


/* const x: {
    bodyText: string;
    isAdmin: boolean;
    postCount: number;
} */

Looks good.


Note that it's quite possible to use something like template literal types to encode more complicated relationships from string literals to types, so that, for example, if "XXX" encodes a type XXX, then "Array<XXX>" will be parsed so that it encodes the type XXX[]. Like this, maybe:

type NameToType<K> =
  K extends keyof DataReturn ? DataReturn[K] :
  K extends `Array<${infer K0}>` ? Array<NameToType<K0>> :
  never;

type CheckData<T> = { [K in keyof T]:
  NameToType<T[K]> extends never ? keyof DataReturn : T[K]
}

declare function registerData<const T extends Record<keyof T, string>>(
  data: T extends CheckData<T> ? T : CheckData<T>
): { [K in keyof T]: NameToType<T[K]> }


const x = registerData({
  bodyText: 'string',
  isAdmin: 'boolean',
  postCount: 'number',
  otherStuff: 'Array<string>'
});
/* const x: {
  bodyText: string;
  isAdmin: boolean;
  postCount: number;
  otherStuff: string[];
} */

But this sort of thing can become arbitrarily complicated, and I'm not about to write an entire type schema parser using template literals. If you want to do it yourself, that's fine, but such endeavors aren't really in scope here.

Playground link to code

Sign up to request clarification or add additional context in comments.

2 Comments

In the second more advanced example, is it possible to change the template literal to another type? (e.g. replace 'Array<${infer K0}>' with MyCustomType<infer K0>). Most of the types here work fine with it, but the Record<keyof T, string> causes errors (as MyCustomType isn't a string), but I've got no clue what I'd replace it with. It looks like NameToType<T[keyof T]> should work, and it does for any values of the custom type, but this causes the type of bodyText, isAdmin, and postCount (any in DataReturn) to become 'never'. A union with NameToType and string has the same issue.
After more testing, Record<keyof T, NameToType<T[keyof T]>> actually works fine, but for some reason the individual string types such as 'string' and 'boolean' all get converted to 'string' unless they are specified explicitly. Any idea if there is a workaround to get typescript to keep recognising the keys as a union of specific strings instead of just any 'string'
0

Based on the respose by jcalz, I've come up with the following solution:

type TypeMap = {
  string: string;
  boolean: boolean;
  float: number;
  integer: number;
  // add entries here if you need it
}

const generatorMap: { [K in keyof TypeMap]: any } = {
    string: () => [faker.lorem.word(), faker.lorem.paragraph()],
    integer: () => [faker.number.int()],
    boolean: () => [true, false],
    float: () => [faker.number.float()]
}

function registerDataTest<T extends Record<keyof T, keyof TypeMap>>(t: T) {
    const entries = Object.entries(t)
        .map(([name, type]) => [name, generatorMap[type as keyof TypeMap]]);
      
    // Other logic for extracting a single value from each array
    // e.g. data = Object.fromEntries(
    //  entries.map(([name, generated]) => [name, generated[0]]));

    return data as { [K in keyof T]: TypeMap[T[K]] };
}

const x = registerDataTest({
  bodyText: 'string',
  isAdmin: 'boolean',
  postCount: 'float'
});

x will have correct types and correct type checking on the input parameters as well.

2 Comments

Is there a reason you took my suggestion and wrote up your own answer instead of allowing me to write it up myself as I said in the comment? It's your prerogative to do so, but I'm wondering why.
Sorry, was just in a bit of a rush, as I was on a bit of a deadline. I'm more than happy to still receive a writeup if you want, as I might be able to apply whatever is happening with Record here to other types. I'd be happy to accept a proper writeup as a better answer, since mine is a little hacked together.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.