Search code examples
typescripttyping

Infer types using own properties


I'm trying to find out if there is a way to infer types in an interface from an implementation's properties.

Simplified example:

interface Options {
  type: 'string' | 'number'
  demanded?: boolean
}

interface Command {
  // The parameter options will contain the interpreted version of the options property
  callback: (options: InferOptionTypings<this>) => void
  options: { [key: string]: Options }
}

// Infer the options
// { type: 'string, demanded: false} | { type: 'string' }   => string | undefined
// { type: 'string, demanded: true }                        => string
// { type: 'number', demanded: false} | { type: 'number }   => number | undefined
// { type: 'number, demanded: true }                        => number
type InferOptionTypings<_ extends Command> = ... // here i've been stuck for very long

I've read the typings of yargs (and this is obviously inspired by yargs), but I've not figured out how to make it work in this style or what I'm missing/if this even is possible.

Example use case:

let command: Command = {
  callback: (options) => {
    options.a // string
    options.b // number | undefined
    options.c // string | undefined
    options.d // error
  },
  options: {
    a: {
      type: 'string',
      demanded: true,
    },
    b: {
      type: 'number',
    },
    a: {
      type: 'string',
    },
  },
}

Solution

  • It is possible, but in order to infer it you should create a function.

    interface Option {
      type: 'string' | 'number'
      demanded?: boolean
    }
    
    /**
     * Translates string type name to actual type
     * Logic is pretty straitforward
     */
    type TranslateType<T extends Option> =
      T['type'] extends 'string'
      ? string
      : T['type'] extends 'number'
      ? number
      : never;
    
    /**
     * Check if demanded exists
     * if true - apply never, because union of T|never produces T
     * if false - apply undefined
     */
    type ModifierType<T extends Option> =
      T extends { demanded: boolean }
      ? T['demanded'] extends true
      ? never
      : T['demanded'] extends false
      ? undefined
      : never
      : undefined
    
    /**
     * Apply TranslateType 'string' -> string
     * Apply ModifierType {demanded:fale} -> undefined or never
     */
    type TypeMapping<T extends Option> = TranslateType<T> | ModifierType<T>
    
    /**
     * Apply all conditions to each option
     */
    type Mapping<T> = T extends Record<string, Option> ? {
      [Prop in keyof T]: TypeMapping<T[Prop]>
    } : never
    
    type Data<Options> = {
      callback: (options: Mapping<Options>) => void,
      options: Options
    }
    const command = <
      /**
       * Infer each option
       */
      Options extends Record<string, Option>
    >(data: Data<Options>) => data
    
    const result = command({
      callback: (options) => {
        type a = typeof options.a
        type b = typeof options.b
        type c = typeof options.c
    
        options.a // string
        options.b // number | undefined
        options.c // string | undefined
        options.d // error
      },
      options: {
        a: {
          type: 'string',
          demanded: true,
        },
        b: {
          type: 'number',
          demanded: false
        },
        c: {
          type: 'string',
        },
      },
    })
    
    

    I left the comments under each type utility

    Playground

    UPDATE Without function:

    type WithoutFunction = Data<{
      a: {
        type: 'string',
        demanded: true,
      },
      b: {
        type: 'number',
        demanded: false
      },
      c: {
        type: 'string',
      },
    }>