Search code examples
typescriptobjectmetaprogrammingtypescript-genericstype-inference

How to transform object field name using type infer in Typescript?


I've implemented a parser builder with transformed object key (it works in js) but having difficulty to transform the inferred type of object key.

Sample Code:

let sampleData = {
  id: 1,
  delete_time$optional: new Date()
}
let parser = inferFromSampleValue(sampleData)

The constructed parser will require a number id and an optional Date delete_time in the input object.

In my initial implementation:

type Parser<T> = {
  // throw runtime error if type not matched
  parse(input: unknown): T
  // typescript type of expected data in string
  type: string
}
function inferFromSampleValue<T>(value: T): Parser<T> {
  // implementation omitted
}

The type signature of the parser result is:

{
  id: number
  delete_time$optional: Date
}

But the desired type signature should be:

{
  id: number
  delete_time?: Date
}

Then I tried to write some generic types:


type InferFieldName<S> = S extends `${infer N}$optional` ? N : S

type ExcludeOptionalFieldName<S> = S extends `${infer N}$optional` ? never : S

type InferType<T> = T extends Record<infer K extends keyof T, infer V>
  ? Partial<Record<InferFieldName<K>, V>> &
      Pick<T, ExcludeOptionalFieldName<keyof T>>
  : T

function inferFromSampleValue<T>(value: T): Parser<InferType<T>> {
  // implementation omitted
}

Then the type signature of the parser result becomes:

{
  id: number
  delete_time?: number | Date
}

Which still isn't the desired outcome because it mixed the types of all fields for the optional fields (it inferred as optional number | Date instead of optional Date).

I tried to do something like below but it seems to be invalid syntax in Typescript:

type InferType<T> = T extends {}
  ? {
      [InferFieldName<P> where P in keyof T]: T[P]
    }
  : T

The example code posted above are simplified version, the actual code involves more types of transforms (for nullable fields and enums) and applied recursively.

This is for the open sourced project "cast.ts" published on github and npm. Linked to related lines: https://github.com/beenotung/cast.ts/blob/644e5bf/src/core.ts#L1067-L1154

Thank you for your help.


Solution

  • One approach is to give inferFromSampleValue() the following call signature:

    declare function inferFromSampleValue<T>(value: T): Parser<
        { [K in keyof T as K extends `${string}\$optional` ? never : K]: T[K] } &
        { [K in keyof T as K extends `${infer P}\$optional` ? P : never]?: T[K] }
    >;
    

    This uses key remapping in mapped types to filter the keys which do or do not end with "$optional". In the first case it filters those keys out, so you end up with only the non-optional properties. In the second case it filters those keys in, but removes the "$optional" suffix. Additionally, the second case uses the ? mapping modifier so that the resulting type has all optional properties. And both cases are intersected together to create the equivalent of a single object type with both the non-optional and optional properties, as intended:

    let sampleData = {
        id: 1,
        delete_time$optional: new Date()
    }
    let parser = inferFromSampleValue(sampleData);
    declare let input: unknown
    const v = parser.parse(input);
    //    ^? const v: { id: number; } & { delete_time?: Date | undefined; }
    v.id.toFixed(1); // okay
    v.delete_time?.getFullYear(); // okay
    

    Looks good!

    Playground link to code