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:
- generic types
- conditional types
- mapped types
- and abusing function inferences to create a wrapper function
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!