Dynamically Generating Type for function return type

114 Views Asked by At

Here is my TypeScript code:

type MetadataTags = {
  album: boolean;
  artist: boolean;
  genre: boolean;
  year: boolean;
  duration: boolean;
  contentType: boolean;
  artwork: boolean;
  directory: boolean;
};

type MusicScanResult<T extends MetadataTags> = {
  url: string;
  id: string;
  title: string;
} & (T['album'] extends true ? { album: string } : {}) & 
    (T['artist'] extends true ? { artist: string } : {}) &
    (T['genre'] extends true ? { genre: string } : {}) &
    (T['year'] extends true ? { year: string } : {}) &
    (T['duration'] extends true ? { duration: number } : {}) &
    (T['contentType'] extends true ? { contentType: string } : {}) &
    (T['artwork'] extends true ? { artwork: string } : {}) &
    (T['directory'] extends true ? { directory: string } : {});

function scanMediaStore<T extends MetadataTags>(metadata: T): MusicScanResult<T> {
  const map = new Map();
  map.set("url","sample");
  map.set("id","1233");
  map.set("title","adarsh");
  if(metadata.album) map.set("album","test");
  if(metadata.artist) map.set("artist","test");
  if(metadata.duration) map.set("duration",0);
  if(metadata.contentType) map.set("contentType","test");
  if(metadata.directory) map.set("directory","test");
  if(metadata.year) map.set("year","test");
  if(metadata.genre) map.set("genre","test");
  if(metadata.artwork) map.set("artwork","test");

  const res = Object.fromEntries(map);

  return res;
}

Explanation

The function will return an object which always contain

{
  url: string;
  id: string;
  title: string;
}

Along with this, if any property of the metadata is true then the return value will also contain that property.

So I am trying to write a dynamic type definition for the function return type. Note that the type of all the properties in the return type is string, except for property "duration", which is number.

Problem

When I directly pass the object to the function, the return type is generated correctly, including the properties that are marked as true in the metadata object. However, when I create a separate variable for the metadata object and pass that variable as the argument, I can only access the URL, ID, and title properties from the result. The other properties are not included in the type.

Calling the function:

// This will work as expected.
const ans = scanMediaStore({
    album: true,
    artist: true,
    genre: false,
    year: false,
    duration: true,
    contentType: true,
    artwork: false,
    directory: false,
});

Screenshot of TypeScript playground

const metadata: MetadataTags = {
    album: true,
    artist: true,
    genre: false,
    year: false,
    duration: true,
    contentType: true,
    artwork: false,
    directory: false,
};

// This will not work as expected.
// Here I can only access url, id, title; the rest are not shown.
const ans = scanMediaStore(metadata);

Screenshot of TypeScript playground

Update: While I appreciate the solutions provided, I'm open to exploring different approaches to achieve the desired type in my code. If anyone has alternative solutions or suggestions to dynamically generate a TypeScript return type based on object properties, please feel free to share. I'm particularly interested in solutions that might offer better type inference or cleaner syntax.

Here is the TypeScript playground.

2

There are 2 best solutions below

0
On

First of all, let's make sure metadata's properties are not widened to just boolean and the object itself is type-safe, which can be achieved with the combination of the satisfies and const assertion:

// const metadata: {
//   readonly album: true;
//   readonly artist: true;
//   readonly genre: false;
//   readonly year: false;
//   readonly duration: true;
//   readonly contentType: true;
//   readonly artwork: true;
//   readonly directory: true;
// }
const metadata = {
  album: true,
  artist: true,
  genre: false,
  year: false,
  duration: true,
  contentType: true,
  artwork: true,
  directory: true,
} as const satisfies MetadataTags;

Now, let's create a type where the keys will be the keys of MetadataTags and the values will be what should function return if this property is true:

type TypeMap = {
  album: { album: string };
  artist: { artist: string };
  genre: { genre: string };
  year: { year: string };
  artwork: { artwork: string };
  duration: { duration: number };
  contentType: { contentType: string };
  directory: { directory: string };
};

Note that the values could be simplified to just string or number if you are sure that you won't add anything else:

type TypeMap = {
  album: string;
  artist: string;
  genre: string;
  year: string;
  artwork: string;
  duration: number;
  contentType: string;
  directory: string;
};

By using a mapped type and key remapping we will drop the properties that are not true, otherwise, the values will be what's in the TypeMap for the specific property:

type Testing<T extends MetadataTags> = {
 [K in keyof TypeMap as T[K] extends true ? K : never]: TypeMap[K];
} 

Testing:

Complex TypeMap:

//  type Result = {
//   album: {
//       album: string;
//   };
//   artist: {
//       artist: string;
//   };
//   artwork: {
//       artwork: string;
//   };
//   duration: {
//       duration: number;
//   };
//   contentType: {
//       contentType: string;
//   };
//   directory: {
//       directory: string;
//   };
// }
type Result = Testing<typeof metadata>

Simple TypeMap:

// type Result = {
//   album: string;
//   artist: string;
//   artwork: string;
//   duration: number;
//   contentType: string;
//   directory: string;
// }
type Result = Testing<typeof metadata>

With the simple version, the only thing left is to intersect the result with the rest of the return type, however in the complex version we have to take the values and make sure that they are intersected:

type ValueOf<T> = T[keyof T];
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never;

type Testing<T extends MetadataTags> =  UnionToIntersection<ValueOf<{
  [K in keyof TypeMap as T[K] extends true ? K : never]: TypeMap[K];
 }>>

You can read more about the ValueOf here and UnionToIntersection here

Testing:

// type Result = {
//   album: string;
// } & {
//   artist: string;
// } & {
//   year: string;
// } & {
//   duration: number;
// } & {
//   contentType: string;
// } & {
//   directory: string;
// } & {
//   artwork: string;
// }
type Result = Testing<typeof metadata>

To make the result type more readable the following utility type from the type-samurai can be used:

type Prettify<T> = T extends infer R
  ? {
      [K in keyof R]: R[K];
    }
  : never;

This type will remove intersections and will show them under a single object:

// type Result = {
//   album: string;
//   artist: string;
//   year: string;
//   artwork: string;
//   duration: number;
//   contentType: string;
//   directory: string;
// }
type Result = Testing<typeof metadata>

Looks good! Let's connect everything together:

type MusicScanResult<T extends MetadataTags> = Prettify<
  {
    url: string;
    id: string;
    title: string;
  } & UnionToIntersection<
    ValueOf<{
      [K in keyof TypeMap as T[K] extends true ? K : never]: TypeMap[K];
    }>
  >
>;

Final testing:

const metadata = {
  album: true,
  artist: true,
  genre: false,
  year: false,
  duration: true,
  contentType: true,
  artwork: true,
  directory: true,
} as const satisfies MetadataTags;

// const ans: {
//   url: string;
//   id: string;
//   title: string;
//   album: string;
//   artist: string;
//   artwork: string;
//   duration: number;
//   contentType: string;
//   directory: string;
// }
const ans = scanMediaStore(metadata);

playground

6
On

Because you are annotating metadata as MetadataTags, it is widened to that type. Because of this, all the properties of metadata or of type boolean, instead of true or false.

Instead of the annotation, you should use satisfies to make sure it satisfies the required type without widening the actual used type.

const metadata = {
    album: true,
    artist: true,
    genre: false,
    year: false,
    duration: true,
    contentType: true,
    artwork: false,
    directory: false,
} satisfies MetadataTags;

const ans = scanMediaStore(metadata);

TypeScript playground