Home > front end >  How to preserve specific string[] type in generic for omit() function?
How to preserve specific string[] type in generic for omit() function?

Time:10-17

I am trying to accurately describe a simple omit() function:

function omit(source, props) {
   return Object.fromEntries(
        Object.entries(source).filter(entry => {
            if (Array.isArray(props)) {
                return !props.includes(entry[0]);
            }

            return entry[0] !== props;
        }),
    );
}

Below I will describe three of my approaches and why they failed. If you clearly understand the problem and know the answer you can skip it and move to answer. :)

Approach 1

Playground

declare function omit<
    O extends Record<string, unknown>, P extends string | string[]
>(
    source: O, props: P
): Omit<
    O, P extends string ? P : P[number]
>;


const o1 = omit({ a: 1, b: 'foo', c: true }, 'b');

o1.a; // no error - correct
o1.b; // error - correct
o1.c; // no error - correct


const o2 = omit({ a: 1, b: 'foo', c: true }, ['b', 'c']);

o2.a; // error - wrong!
o2.b; // error - correct
o2.c; // error - correct

It was the most obvious solution for me.

Unfortunately the o2.a expression produces an error Property 'a' does not exist on type 'Omit<{ a: number; b: string; c: boolean; }, string>'. Apparently, TS loses the specific string types in string[] and merges they in a common string.

Approach 2

Playground

declare function omit<
    O extends Record<string, unknown>, 
    P1 extends string,
    P2 extends string,
    P3 extends string,
    P extends string | [P1] | [P1, P2] | [P1, P2, P3]
>(
    source: O, props: P
): Omit<
    O, P extends string ? P : P[number]
>;


const o1 = omit({ a: 1, b: 'foo', c: true }, 'b');

o1.a; // no error - correct
o1.b; // error - correct
o1.c; // no error - correct


const o2 = omit({ a: 1, b: 'foo', c: true }, ['b', 'c']);

o2.a; // no error - correct
o2.b; // error - correct
o2.c; // error - correct

The approach works totally fine, but has the obvious drawback — it is impossible to use (to describe in function type) arbitrary number of properties to be omitted.

Approach 3

Playground

declare function omit<
    O extends object,
    P extends string[]
>(
    source: O,
    ...props: P
): Omit<O, P[number]>;

const o1 = omit({ a: 1, b: 'foo', c: true }, 'b');

o1.a; // no error - correct
o1.b; // error - correct
o1.c; // no error - correct


const o2 = omit({ a: 1, b: 'foo', c: true }, 'b', 'c');

o2.a; // no error - correct
o2.b; // error - correct
o2.c; // error - correct

I have stolen the solution from the immortal lodash's typedefs. And to my surprise it works!

For some reason (please tell me if you understand why) TS saves the specific string[] type for the rest parameters, but not for an array in a regular parameter.

Alas, this solution is still not what I'm looking for: it uses rest parameters, not the single regular parameter.

So...

Is there a way to describe my specific function? Why does TS save a specific string[] type in a generic for a rest parameters, but not for an array in a regular parameter?

Thank you.

CodePudding user response:

Your function is best described with two overloads:

// Single prop
declare function omit<O extends Record<string, any>, P extends string>(
    source: O,
    prop: P
): Omit<O, P>

// Array of props
declare function omit<O extends Record<string, any>, P extends string>(
  source: O,
  props: P[]
): Omit<O, P>

// Then...
const o1 = omit({ a: 1, b: 'foo', c: true }, 'b');

o1.a; // no error - correct
o1.b; // error - correct
o1.c; // no error - correct

const o2 = omit({ a: 1, b: 'foo', c: true }, ['b', 'c']);

o2.a; // no error - correct!
o2.b; // error - correct
o2.c; // error - correct

// Allow prop values not in `O`
const o3 = omit({ a: 1, b: 'foo', c: true }, ['b', 'c', 'd'])

o3.a; // no error - correct
o3.b; // error - correct
o3.c; // error - correct
o3.d; // error - correct

Here's a link to the playground as well.

CodePudding user response:

It's more of an art than a science when it comes to getting the compiler to look at a string literal value like "a" and keep its type to be the literal type "a" instead of widening to the string type. If users of omit() are willing to explicitly make their intentions clear, say, by using a const assertion, then things will start working for you even with approach #1:

const o2 = omit({ a: 1, b: 'foo', c: true }, ['b' as const, 'c' as const]);
o2.a; // okay
o2.b; // error 
o2.c; // error 

Otherwise you are relying on the heuristic rules the compiler uses. If you look at microsoft/TypeScript#10676 you can see a list of these rules.

The approach that works for me most consistently is: make a generic type parameter that is constrained to string (for string literals), like P extends string, and then use that type parameter everywhere I want the compiler to treat string literals as a string literal type. That leads to the following implementation of omit():

declare function omit<
    O extends Record<string, unknown>, P extends string
>(
    source: O, props: P | P[]
): Omit<O, P>;

Now you can see that everything behaves as desired:

const o1 = omit({ a: 1, b: 'foo', c: true }, 'b');
// function omit<{ ... }, "b">(...)
o1.a; // okay
o1.b; // error 
o1.c; // okay
    
const o2 = omit({ a: 1, b: 'foo', c: true }, ['b', 'c']);
// function omit<{ ... }, "b" | "c">(...)
o2.a; // okay
o2.b; // error
o2.c; // error

Of course, these are just inference hints and do not guarantee that someone won't overcome them:

const oops = ["b", "c"];
// const oops: string[]

const o3 = omit({ a: 1, b: "foo", c: true }, oops)
// const o3: Omit<{ ... }, string>

Here the user has declared oops in a way that the compiler infers string[] as its type. That has happened before the omit() has a chance to do anything. And so the P parameter is inferred to be string because oops is of type string[]. And you get a useless type out of it.

I'm not sure how to suggest you proceed there. It might not be much of a concern or you might be happy with the current outcome. You could make it so that the compiler rejects calls to omit() if P is inferred as string like this:

declare function omit<
    O extends Record<string, unknown>, P extends string
>(
    source: O, props: string extends P ? never : P | P[]
): Omit<O, P>;

which works the same for the good examples, but causes the following call to warn:

const o3 = omit({ a: 1, b: "foo", c: true }, oops) // error!
// ----------------------------------------> ~~~~
// Argument of type 'string[]' is not assignable to parameter of type 'never'.

There's certainly some diminishing returns when it comes to handling edge cases, so I'll stop belaboring the point.

Playground link to code

  • Related