leeoniya / uPlot

📈 A small, fast chart for time series, lines, areas, ohlc & bars
MIT License
8.51k stars 371 forks source link

[ts] conflicting font types #595

Open vthriller opened 2 years ago

vthriller commented 2 years ago

uPlot.Axis currently defines:

        font?: CanvasRenderingContext2D['font'];
        labelFont?: CanvasRenderingContext2D['font'];

While true for values passed into constructor, various callbacks actually need to deal with values processed by pxRatioFont(), which, it seems, actually has (non-null? not sure about that) return type [string, number, number].

leeoniya commented 2 years ago

this is a general typings issue unfortuntely. a lot of the passed Options are internally converted to getters (or in this case parsed tokens) and re-exposed as such. when passed during init they may be optional or multiple types but after init, they are non optional and exposed uniformly for read-back. i'm not sure how to express these semantics without duplicating all types with slight tweaks and bloating the typings file + maintenance nightmare. if you have a good solution i'm eager to try it!

Venryx commented 2 years ago

I'm not sure exactly what the typing issue is, but would something like the below work?:

interface Options {
    a?: string;
    b?: [string, number];
    c?: number;
}
let opt: Options = {}; // no TS errors

type Options2 = {
    [P in keyof Options]-?: // the "-?" means "remove the optional flag for these properties"
        Options[P] extends string|undefined                     ? [string, boolean] :
        Options[P] extends [string, number]|undefined           ? [string, number, boolean] :
        Options[P] extends number|undefined                     ? [number, boolean] :
        never;
}
let opt2: Options2 = { // no TS errors
    a: ["", true],
    b: ["", 1, true],
    c: [1, true],
};

It's just an example showing how you can use TypeScript conditional-types/type-extensions (not sure the correct terminology) to loop through the properties in one type, and replace them with another set of types.

EDIT: Ah, found some documentation on it: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html

leeoniya commented 2 years ago

interesting. not sure that route is ideal though.

effectively i want:

interface Options {
  a?: string;
  b?: [string, number] | (u) => number;
  c?: number;
}

// 1. make a,b,c non-optional
// 2. re-use the callback variant of Options.b (something like <Pick> for type disjunctions)
interface ComputedOptions extends Options {
  b: (u) => number
}

i guess 2. can be handled by defining a new type for the callback variant of b, but this would need to be done in a ton of places, which i'd like to avoid.

generally, these re-exposed options are not terribly useful externally because they sometimes require extra params that are only available from internal calcs. i'm not sure it's worth investing this effort rather than simply passing down the original options object alongside the uplot instance if you really want to read back the original settings as they were provided.

Venryx commented 2 years ago

i guess 2. can be handled by defining a new type for the callback variant of b, but this would need to be done in a ton of places, which i'd like to avoid.

Would something like this work?:

interface Options {
  a?: string;
  b?: number;
  c?: [string, number] | (u) => number;
}
type ComputedOptions = {
    [P in keyof Options]-?: // the "-?" means "remove the optional flag for these properties"
        // for any field where the type definition is undefined|<function type>|[X, Y?, Z?], change it to only accept the declared function-type
        Options[P] extends undefined|Function|[infer T1]                                ? Extract<Options[P], Function> :
        Options[P] extends undefined|Function|[infer T1, infer T2]                      ? Extract<Options[P], Function> :
        Options[P] extends undefined|Function|[infer T1, infer T2, infer T3]            ? Extract<Options[P], Function> :
        Options[P];
}

In the above, ComputedOptions thus becomes:

interface ComputedOptions {
  a: string;
  b: number;
  c: (u) => number;
}

Giving this usage:

// valid
const o1: ComputedOptions = {a: "", b: 1, c: a=>3};
// invalid
const o2: ComputedOptions = {a: "", b: 1, c: 1};
const o3: ComputedOptions = {a: "", b: 1, c: "1px"};
leeoniya commented 2 years ago

how gnarly would this look with nested options?

ComputedOptions
  series: ComputedSeriesOptions
  axes: ComputedAxesOptions
    grid: ComputedGridOptions

seems like we'd get some really complex or multi-layer generic typings that i wont be able to fully wrap my head around or maintain effectively. at that point just copy/paste/modify starts looking pretty good, but 🤷

Venryx commented 2 years ago

You can use a generic type-replacer system:

type NarrowMultiTypeFieldsToJustFuncType<T> = {
    // the "-?" means "remove the optional flag for these properties"
    [P in keyof T]-?: ExtractFuncTypeFromMultiType<T[P]>;
}
type ExtractFuncTypeFromMultiType<T> =
    // where type is undefined|<function type>|[X, Y?, Z?], extract only the declared function-type
    T extends undefined|Function|[infer T1]                                 ? Extract<T, Function> :
    T extends undefined|Function|[infer T1, infer T2]                       ? Extract<T, Function> :
    T extends undefined|Function|[infer T1, infer T2, infer T3]             ? Extract<T, Function> :
    // for other types, leave unmodified
    T;

Then do this:

type ComputedOptions = {
    series: NarrowMultiTypeFieldsToJustFuncType<ComputedSeriesOptions>;
    axes: NarrowMultiTypeFieldsToJustFuncType<ComputedAxesOptions>;
    grid: NarrowMultiTypeFieldsToJustFuncType<ComputedGridOptions>;
}
leeoniya commented 2 years ago

i should have been more clear that ComputedGridOptions are nested inside ComputedAxesOptions (axis.grid).

Venryx commented 2 years ago

Ah. Well, that shouldn't complicate things too much, as the generic type-helper can be used on multiple levels.

I guess the main question is whether it's consistently the case that, when a field has multiple types, you always want the callback-type to be used as the field's ComputedOptions type. (if that's not the case, then hard-coding may still be better)

leeoniya commented 2 years ago

I guess the main question is whether it's consistently the case that, when a field has multiple types, you always want the callback-type to be used

yes, i think this is pretty much always the case.

leeoniya commented 2 years ago

before committing to this route, i'd like to see an initial scaffold PR for what this might look like; don't bother doing the entire typings file, but a decent amount of it that covers different cases, including multi-level nested computed types. if it looks reasonable enough to maintain, then we can go ahead.

Venryx commented 2 years ago

I unfortunately do not have the time/motivation for this right now. I can provide technical support (ie. answers to "how do I make the type system do this?" questions) if anyone else wants to take on the job, though. (the type-transformers above should be enough to get started)