0

I am trying to use generic types in the following example:

export interface CommonFilter<T> {
    value?: T,
    renderValue: (val?: T) => string
}

export interface StringFilter extends CommonFilter<string> {
    type: "string",
    customStringAttr?: any
}

export interface NumberFilter extends CommonFilter<number> {
    type: "number",
    customNumberAttr?: any
}

export type Filter = StringFilter | NumberFilter;

So far, all good.

The problem comes when I want to use the renderValue of each filter:

const filters: Filter[] = [
  { type: "string", value: "something", renderValue: val => `My string val --> ${val ?? "none"}` },
  { type: "number", value: undefined, renderValue: val => `My number val --> ${val?.toFixed(2) ?? "none"}` }
];

filters.forEach(f => {
  f.renderValue(f.value); //this line fails 
})

The error that I get is:

Argument of type 'string | number | undefined' is not assignable to parameter of type 'undefined'. Type 'string' is not assignable to type 'undefined'.

Does anyone have an idea how to fix it?

grisha
  • 1
  • 1

3 Answers3

1

Because renderValue and value is optional properties that mean type's those fields is T | undefined

export interface CommonFilter<T> {
  value?: T, // real type: T | undefined
  renderValue?: (val?: T) => string // real type ((val?: T) => string) | undefined
}

So, when call possible undefined function will occur typescript error. You can try check undefined before call renderValue. With value field real type is string | number | undefined, you can cast to any.

filters.forEach(f => {
  if (!f.renderValue) return;
  console.log(f.renderValue(f.value as unknown as any)); // real type of val param is number | string | undefined.
})
Doan Thai
  • 561
  • 3
  • 10
  • since when breaking type safety is considered a solution to the question, where the type-safety is the whole point? Check the approach described in [here](https://github.com/microsoft/TypeScript/pull/47109), which I implemented in my answer – wonderflame Jul 20 '23 at 12:19
1

Either check the f.type to narrowing the type of the f.value:

filters.forEach(f => {
  if (f.renderValue) {
    if (f.type == "number") {
      f.renderValue(f.value);  
    }
    else {
      f.renderValue(f.value);
    }
  }

})

or simply change the value type in renderValue to unkown and check the renderValue if exist:

 renderValue?: (val?: unknown) => string
filters.forEach(f => {
if(f.renderValue){
    f.renderValue(f.value); 
}
})
Mouayad_Al
  • 1,086
  • 2
  • 13
  • 1
    if / else approach is not scalable. For every added filter the user will have to add a condition. `unknown` isn't a solution at all. It breaks the whole point of the generics – wonderflame Jul 20 '23 at 12:18
1

The compiler is unable to handle the "correlated union types" described in ms/TS#30581, Instead of using assertion, which breaks the type-safety, or having a switch case for every type of filter, let's use the suggested approach described in ms/TS#47109.

First, we will need a type that would store all necessary type for filter:

type TypeMap = {
  string: {
    type: string;
    extra: { customStringAttr?: any };
  };
  number: {
    type: number;
    extra: { customStringAttr?: any };
  };
};

extra field is used to add some additional fields that you actually need.

Now, let's recreate your filter types using mapped types:


type FilterObject = {
  [K in keyof TypeMap]: {
    type: K;
    value?: TypeMap[K]['type'];
    renderValue?: (val?: TypeMap[K]['type']) => string;
  } & TypeMap[K]['extra'];
};

Testing:

type FilterObject = {
    string: {
        type: "string";
        value?: string | undefined;
        renderValue?: ((val?: string | undefined) => string) | undefined;
    } & {
        customStringAttr?: any;
    };
    number: {
        type: "number";
        value?: number | undefined;
        renderValue?: ((val?: number | undefined) => string) | undefined;
    } & {
        ...;
    };
}

To actually get the array of filters we will use the ValueOf described in this answer:

type ValueOf<T> = T[keyof T];

// (({
//   type: "string";
//   value?: string | undefined;
//   renderValue?: ((val?: string | undefined) => string) | undefined;
// } & {
//   customStringAttr?: any;
// }) | ({
//   type: "number";
//   value?: number | undefined;
//   renderValue?: ((val?: number | undefined) => string) | undefined;
// } & {
//   ...;
// }))[]
type Result = ValueOf<FilterObject>[]

const filters: ValueOf<FilterObject>[] = [
  {
    type: 'string',
    value: 'something',
    renderValue: (val) => `My string val --> ${val ?? 'none'}`,
  },
  {
    type: 'number',
    value: undefined,
    renderValue: (val) => `My number val --> ${val?.toFixed(2) ?? 'none'}`,
  },
];

For finishing the approach we will need a generic function that will accept a generic parameter constrained by keyof TypeMap and the argument will be the whole filter under that key:

const render = <T extends keyof TypeMap>(arg: FilterObject[T]) => {
  arg.renderValue?.(arg.value);
};

Usage:

filters.forEach((f) => {
  render(f); // no error
});

playground

wonderflame
  • 6,759
  • 1
  • 1
  • 17