Ts-toolbelt: Add a RequireOnlyOne object type

Created on 11 Aug 2019  路  20Comments  路  Source: millsp/ts-toolbelt

馃崺 Feature Request

Describe the solution you'd like

It will be a really nice addition to add a require only one Object type.
The basic idea behind it is that given an Object with multiple keys, create a union type in a way that a sub set of keys from the object can only exists on its own with out the others.
E.G

interface I {
    y: number;
    x1: string;
    x2: number;
}
type IUnion = RequireOnlyOne<I, 'x1' | 'x2'>;
/*
{
    y: number;
    x1: string;
} | 
{
    y: number;
    x2: number;
}
*/

I currently achieve that type using the following:

type RequireOnlyOne<T, Keys extends keyof T = keyof T> =
    Pick<T, Exclude<keyof T, Keys>>
    & {
    [K in Keys]-?:
    Required<Pick<T, K>>
    & Partial<Record<Exclude<Keys, K>, undefined>>
}[Keys];

@pirix-gh what do you think?

bug feature

Most helpful comment

@pirix-gh you are the best! Will get to check it right away

All 20 comments

I was just looking for it yesterday :laughing: :+1:

Hi @regevbr, glad to see you,

Yes we can add such a type. I just have a few questions:

  • Is it voluntary to make the fields also optional?
// This is what I got from your code
type IUnion = {
    y: number;
    x1: string;
    x2?: undefined; // I can make this disappear if needed
} | {
    y: number;
    x2: number;
    x1?: undefined; // I can make this disappear if needed
}
  • Can you give me one or two use-cases? I never came across such scenario.
  • Is it to say, I either want this part or that part of an object?

@tonivj5 @regevbr

Here is a version that preserves the type modifiers (readonly, ? ...). Don't hesitate to use Compute if your types don't render properly, it forces them to render to something we can read. So here's my implementation:

import {A, O} from 'ts-toolbelt';

interface I {
    a: 'a';
    b?: 'b';
    c?: 'c';
    d?: 'd';
}

type IUnion = EitherWith<I, 'b' | 'c' | 'd'>;

type EitherWith<O extends object, K extends (string | number | symbol)> =
    A.Compute<O.Omit<O, K> & {   // Merge all but K
        [P in K]: O.Pick<O, P> // With K possibilities
    }[K]>

/*
type IUnion = {
    a: "a";
    b?: "b" | undefined;
} | {
    a: "a";
    c?: "c" | undefined;
} | {
    a: "a";
    d?: "d" | undefined;
}
*/

@pirix-gh thanks for your response.

In my use case I need it as I wrote it, as I want to enforce that the other properties can't be used (that is why I set them to undefined.

I use that type in https://github.com/regevbr/json-expression-eval wheres I want to enforce an object to have a single property ONLY out of dynamically list of properties.

type EitherWith<O extends object, K extends keyof O = keyof O> = Any.Compute<
  Object.Omit<O, K> &
    {
      [P in K]-?: Object.Required<Object.Pick<O, P>> & Object.Optional<Object.Record<Union.Exclude<K, P>, undefined>>;
    }[K]
>;
/*
type IUnion = {
    a: "a";
    b: "b";
    c?: undefined;
    d?: undefined;
} | {
    a: "a";
    c: "c";
    b?: undefined;
    d?: undefined;
} | {
    a: "a";
    d: "d";
    b?: undefined;
    c?: undefined;
}
*/

Maybe we can write a strict controllable version?

By the way, using Compute in my library causes everything to fail, as it causes the type to resolve to never, so we should not use it in actual code

Hey @regevbr, I will include this type if we can agree to a more general use case (and put words on it).

I am not fond of the undefined as it is because also "other properties can't be used". But if they're not present (in my implementation), you can't use them. Maybe it's specifically related to your project, so you should write your own type since this lib is a general-purpose tool.

And we need too make sure that the modifiers are preserved, since it has to be for general purpose.

Those are my prerequisites for including this type. I need to know more about clear use-cases that could serve multiple scenarios.

If I use your implementation in my library, the behavior is very strange.
The computed type for expression in https://github.com/regevbr/json-expression-eval/blob/8086845092722cb100dbd41304a983338fe65afa/src/example/example.ts#L17 is:

type x = {
        not: Expression<ExpressionContext, {
            user: (user: string, context: { userId: string; }) => boolean;
            maxCount: (maxCount: number, context: { times: number; }) => boolean;
        }>;
    }
    |
    {
        or: Expression<ExpressionContext, {
            user: (user: string, context: { userId: string; }) => boolean;
            maxCount: (maxCount: number, context: { times: number; }) => boolean;
        }>
        [];
    }
    |
    {
        and: Expression<ExpressionContext, {
            user: (user: string, context: { userId: string; }) => boolean;
            maxCount: (maxCount: number, context: { times: number; }) => boolean;
        }>
        [];
    }
    |
    {
        user: string;
    }
    |
    {
        userId: string | EqualCompareOp<ExpressionContext, "userId"> | NotEqualCompareOp<ExpressionContext, "userId"> | GtCompareOp<ExpressionContext, "userId"> | GteCompareOp<ExpressionContext, "userId"> | LtCompareOp<ExpressionContext, "userId"> | LteCompareOp<ExpressionContext, "userId">;
    }
    |
    {
        times: number | EqualCompareOp<ExpressionContext, "times"> | NotEqualCompareOp<ExpressionContext, "times"> | GtCompareOp<ExpressionContext, "times"> | GteCompareOp<ExpressionContext, "times"> | LtCompareOp<ExpressionContext, "times"> | LteCompareOp<ExpressionContext, "times">;
    }
    |
    {
        maxCount: number;
    }

But it is still valid to set it like:

let expression: Expression<ExpressionContext, ExpressionFunction> = {
    user: '[email protected]',
    times: 3,
};

It seems that the following reproduces the same "issue" and is shorter to understand:

type x = { a: string } | { b: number };

const x1: x = {a: '', b: 2};

So I guess that is a typescript intended behavior, and in that case your suggestion will not work for my use case. I'm not even sure what is the effective affect of your implementation according to my exmaple?

The reason my library only allows a single property is the fact that in every object "level" there can only by a single operand, and I can only discover that operand by computing the singe key the "level" has and evaluate it. If you pass more than 1 argument to a level, it will fail at run time which is undesired.

@tonivj5 maybe you can shed some light on your use case?

@regevbr OOOooh I see now. This is kind of strange. I understand your problem now. I will look into this soon.

@pirix-gh indeed seems like it, and the workaround suggested there is an extended form of my solution. How should we proceed?

Hola @regevbr @tonivj5

You guys can now use Either

I hope this ts issue will resolve one day

@pirix-gh you are the best! Will get to check it right away

@pirix-gh sadly it doesn't work :-( as I mentioned earlier here, the usage of Compute causes issues.

I found an example that does't work, but not sure that is why it doesn't work for me:

interface I2 {
    f: string;
}

interface I {
    a: 'a';
    b?: 'b';
    c?: 'c';
    d?: 'd';
}

type IUnion = tObject.Either<I | I2, 'b' | 'c' | 'd'>; // resolved to {}

If you want to see the errors I get, best that you checkout my library as change in index.ts the RequireOnlyOne type to

export type RequireOnlyOne<T extends object> = Object.Either<T, keyof T>;

If I remove the Compure part from Strict, it works nicely for me, no idea why it matters so much...

Ah, let me check this out. Compute is strictly for the user readability.

@regevbr, the problem was coming from Compute that was too strict, then your type did not even resolve to any. I've changed it for the looser implementation from Titian. I've pushed out a fix. Sorry 'bout that!

@pirix-gh cool works now! Again thanks a lot :-)

Somehow you always bring to me the worst issues :rofl:
Just joking, thanks, I really appreciate your help @regevbr

@pirix-gh haha, I just work with very complex typings :-)

Was this page helpful?
0 / 5 - 0 ratings