Dynamically extract type in TypeScript

Viewed 439

I am trying to dynamically map one type to another based on certain flags, which might result in optional fields.

type Constraint = {
  required: boolean,
  callback: () => any,
}

type Schema = Record<string, Constraint>;

const mySchema: Schema = {
  bar: {
    required: true,
    callback: () => 1
  },
  foo: {
    required: false,
    callback: () => true
  }
}

type MapSchemaToOutput<T extends Schema> = {
  [K in keyof T as T[K]['required'] extends true ? K : never]: ReturnType<T[K]['callback']>
} & {
  [K in keyof T as T[K]['required'] extends false ? K : never]?: ReturnType<T[K]['callback']>
}

type Output = MapSchemaToOutput<typeof mySchema>;

The end goal is to have Output equal:

{
  bar: number,
  foo?: boolean
}

I know I can do the mapping by hand, interested to know if this can be done dynamically.

2 Answers

Your MapSchemaToOutput type is actually correct given the appropriate T type. However it has some limitations when we apply it to typeof mySchema.

Record

Schema is a Record which means by definition that its keys are every string. We lose the ability to see the specific keys which are actually present.

Your map type is ok because of extends. But we don't want to apply the type Schema to the variable mySchema. We need to get a more specific type for it.

Boolean Types

The type for the boolean values true and false will generally be inferred as boolean instead of their literal values. If the type of T[K]['required'] is boolean then that doesn't extend true or false so it won't meet either condition of the map.

I recommend removing the extends check for the optional property keys such that all properties will be included as optional by default. Including required values in both places is not an issue because they are joined with & so it must be present in order to match both conditions.

as const & readonly

In order to get the literal boolean values of mySchema we need to use as const. This infers all values as literals. It also makes the type readonly and the mapped output will become readonly as well. We can remove the readonly by adding -readonly to the keys in the MapSchemaToOutput type.

Putting that all together, we get:

type MapSchemaToOutput<T extends Schema> = {
 -readonly[K in keyof T as T[K]['required'] extends true ? K : never]: ReturnType<T[K]['callback']>
} & {
 -readonly[K in keyof T]?: ReturnType<T[K]['callback']>
}
type Output = MapSchemaToOutput<typeof mySchema>;

resolves to:

type Output = {
    bar: number;
} & {
    bar?: number | undefined;
    foo?: boolean | undefined;
}

Typescript Playground Link

You approach works as-is, with one change.

The issue is that the : Schema annotation is "throwing away type information":

const mySchema: Schema = {
   //...
};

With that annotation, TS only remembers that mySchema is Record<string, Constraint>, not any of the specific structure of the object.


One fix is as const:

const mySchema = {
    //...
} as const;

This preserves the literal types within the object. However, there's no longer any constraints on the contents of mySchema, and any errors defining mySchema would have to be caught by the usage, rather than at definition-time.


A better fix is to use a helper function to introduce a constraint, without annotating the type directly:

function buildSchema<T extends Schema>(schema: T) { return schema; }

const mySchema = buildSchema({
   //...
});

Due to the <T extends Schema> constraint, TS will raise an error, as before, if the schema object doesn't match the specified type.

But unlike annotating the object's type, this type returned by this function is unchanged from the literal object which is passed to the function: so no type information is lost.

With this change, the rest of the types work as expected

Related