Home > OS >  How can I make a mapped type retain its types when accessing a variable?
How can I make a mapped type retain its types when accessing a variable?

Time:03-30

I'm trying to retain the types of an object with string keys with values that can be either two types. A simple example:


type Option1 = number

type Option2 = string

interface Options {
  readonly [key: string]: Option1 | Option2
}

const options: Options = { test: 1, world: "true"}

I would like it, where if I do options.test, since it's readonly, it's able to infer that it is a number.

Current code:

interface Option<T, R> {
    readonly name: string;
    readonly type: R;
    value: T;
    readonly defaultValue: T;
  }

  type TextOption = Option<string, "text">;
  type BooleanOption = Option<boolean, "boolean">;
  type ColorOption = Option<string, "color">
  type NumberOption = Option<number, "number">
  type Options = { readonly [key: string] : TextOption | BooleanOption | ColorOption | NumberOption }

  interface RenderSystem {
    name: string,
    options: Options
  }

  interface CanvasRenderSystem extends RenderSystem {
    type: "canvas";
    render: (value: string, canvas: HTMLCanvasElement, options: Options) => void;
    currentCanvas?: HTMLCanvasElement;
  }

  interface TextRenderSystem extends RenderSystem {
    type: "text";
    lineSpacing: string;
    tracking: string;
    render: (value: string, options: Options) => string;
  }

  type AnyRenderSystem = TextRenderSystem | CanvasRenderSystem

  const renderSystems: AnyRenderSystem[] = [{
    type: "canvas",
    name: "Simple Image",
    render: (value, canvas, options) => {
      clearCanvas(canvas)
      renderCanvas({ 
        value, 
        foregroundColor: options.foregroundColor.value as string,
        backgroundColor: options.backgroundColor.value as string
      }, canvas)
    },
    options: {
      foregroundColor: { type: "color", name: "Foreground Color", value: "#000000", defaultValue: "#000000" },
      backgroundColor: { type: "color", name: "Background Color", value: "#ffffff", defaultValue: "#ffffff" }
    }
  }]

When I call the first renderSystem's render method, the renderSystem's type is defined as a CanvasRenderSystem

Any improvements non-related to the question can be left in the comments -- I haven't really improved on the rest of the types yet. As shown in the code, I'm using as string, but I'm looking for a better way to do so.

CodePudding user response:

With the given constraints, you'll have to pull out the fullstops and all the TS knowledge possible to do something like this. I have done this many times but you'll need to know the following:

Here is a simple example, answering your simple question

type ReturnCreateOptions<T extends Options> = {
  [K in keyof T]: T[K] extends number ? number : string
}

const createOptions = <T extends Options>(o: T): ReturnCreateOptions<T> => o as any

const optionsWrapped = createOptions({test: 1, world: "true"})

type testb1 = typeof optionsWrapped.test
//  ^? `type testb1 = number`
type testb2 = typeof optionsWrapped.world
//  ^? `type testb2 = string`

Then we can expand it out to your given code

interface Option<T, R> {
  readonly name: string;
  readonly type: R;
  value: T;
  readonly defaultValue: T;
}

type TextOption = Option<string, "text">;
type BooleanOption = Option<boolean, "boolean">;
type ColorOption = Option<string, "color">
type NumberOption = Option<number, "number">
type OptionsAdvanced = { readonly [key: string] : TextOption | BooleanOption | ColorOption | NumberOption }

type ReturnCreateOptionsAdvanced<T extends OptionsAdvanced> = 
  {
    [K in keyof T]: 
      T[K]['type'] extends 'text'
        ? TextOption
      : T[K]['type'] extends 'boolean'
        ? BooleanOption
      : T[K]['type'] extends 'color'
        ? ColorOption
      : NumberOption
  }

type RenderSystemParam<OptionsType extends ReturnCreateOptionsAdvanced<any>> = 
// Default Render System Key/Value
({
  name: string
  options: OptionsType
} & ({ //Canvas
  type: 'canvas';
  render: (value: string, canvas: HTMLCanvasElement, options: OptionsType) => void
  currentCanvas?: HTMLCanvasElement
} | { // Text
  type: "text";
  lineSpacing: string;
  tracking: string;
  render: (value: string, options: OptionsType) => string;
}))[]

const createRenderSystems = <T extends ReturnCreateOptionsAdvanced<any>>(o: RenderSystemParam<T>): RenderSystemParam<T> => o;

//Mock renderCanvas function
const renderCanvas = (...args: any[]): void => undefined;

const renderSystems = createRenderSystems(
  [{
    type: "canvas",
    name: "Simple Image",
    render: (value, canvas, options) => {
      //clearCanvas(canvas)
      renderCanvas({ 
        value, 
        foregroundColor: options.foregroundColor.value,
        backgroundColor: options.backgroundColor.value //string | number | boolean? Huh?
      }, canvas)
    },
    options: {
      foregroundColor: { type: "color", name: "Foreground Color", value: "#000000", defaultValue: "#000000" },
      backgroundColor: { type: "color", name: "Background Color", value: "#ffffff", defaultValue: "#ffffff" },
      test: {type: 'wack'} //Error! That's because our options is wrong!
    }
  }]
)

const renderSystems2 = createRenderSystems(
  [{
    type: "canvas",
    name: "Simple Image",
    render: (value, canvas, options) => {
      //clearCanvas(canvas)
      renderCanvas({ 
        value, 
        foregroundColor: options.foregroundColor.value, //string! Hooray!
        backgroundColor: options.backgroundColor.value 
      }, canvas)
    },
    options: {
      foregroundColor: { type: "color", name: "Foreground Color", value: "#000000", defaultValue: "#000000" },
      backgroundColor: { type: "color", name: "Background Color", value: "#ffffff", defaultValue: "#ffffff" },
    }
  }]
)

View this example on TS Playground, play around for a bit to try to understand all that's happening here, at it is somewhat complex.

Notice that if the options key/value is incorrect, we won't get the right type at all. But if we get rid of it, it will work!

  • Related