busypeoples / spected

Validation library
MIT License
703 stars 32 forks source link

TypeScript typings #105

Open benneq opened 5 years ago

benneq commented 5 years ago

I created a pull request in the DefinitelyTyped repository: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/31953

It's basically working, but some things could be nicer (or better typed):

  1. Generics for Spec and Result
  2. Typings if SpecValue is a function

Especially point 2 is hard for me to figure out...

Example(s):

const spec = {
    a: [[ values => R.all((val) => false, values), 'msg']],
    b: R.map(R.always([[(val) => false, 'msg']])),
    c: R.map(() => [[(val) => false, 'msg']])
}

I have no clue what those function signatures for a, b and c are. I know from your source code, that those functions have a single argument (= value). But I have no clue about the return types.

I'm no Ramda / Functional Programming export. Though some help would be appreciated 😄 Especially when it comes to currying I'm lost 😆

busypeoples commented 5 years ago

Excellent thank you very much! I will try to have look in the coming days.

benneq commented 5 years ago

It finally got merged :)

benneq commented 5 years ago

I'd really need some help for the typings...

I'm one step further now:

declare function spected<ROOTINPUT, SPEC = Spec<ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

export type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

export type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object) // = anything that's not a function
    | ((value: INPUT, field: string) => any);

export type Spec<INPUT, ROOTINPUT = any> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>;

export type SpecValue<INPUT, ROOTINPUT = any> =
    | ReadonlyArray<[Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>]>
    | ((value: INPUT) => any) // HERE I NEED HELP
    | Spec<INPUT, ROOTINPUT>;

// This is much much much better, but still not finished:
// export type Result<INPUT, SPEC> = INPUT extends {[key: string]: infer U}
//     ? {[key in keyof INPUT]: Result<INPUT[key], any>}
//     : true | string[];

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>}

export default spected;

Changes:

What's missing:

The Result type will become kinda complicated, I guess. The type of each result depends on the type of spec, as far as I know so far. HERE I NEED YOUR HELP!

busypeoples commented 5 years ago

Thanks for the great work @benneq! Will finally have look tomorrow.

busypeoples commented 5 years ago

I will checkout your type definitions and add input to the missing parts.

benneq commented 5 years ago

The DefinitelyTyped repo doesn't include the latest version yet. So you may copy and paste the code from my comment above and use it.

There's a small problem with TypeScript's type inference for tuples. Though you should write your TypeScript code like this:

import spected, { Spec } from 'spected';

const data = {
  foo: {
    bar: 42
  }
}

const rules: Spec<typeof data, typeof data> = {
  foo: {
    bar: [[(value) => value > 9000, "error"]]
  }
}

const res = spected(rules, data);

because this does not work as expected:

spected({
  foo: {
    bar: [[(value) => value > 9000, "error"]] // it will recognize this as Array<Function | string> 
  }  // instead of [Function, string]
}, data);
busypeoples commented 5 years ago

Thanks for the info! I will use the above type definitions. Let's see how far we can get with this.

benneq commented 5 years ago

First let's try to get the SpecValue working. The only missing part should be the ((value: INPUT) => any) (here it needs something better than any)

After that, I should hopefully be able to finish the Result type.


Some further explanations:

busypeoples commented 5 years ago

Haven't tried it yet, but shouldn't this be the type?

((value: INPUT) =>  ReadonlyArray<[Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>]>

It should always return the predicates. Can you verify? I'm currently setting up TypeScript.

Correction:

((value: INPUT) =>  SpecValue<INPUT, ROOTINPUT = any>

It might return any of the three possible specValues. Not sure if we can do recursive type definitions with TypeScript, but this could work with an interface?

benneq commented 5 years ago

I'll try that and will report back in a few minutes :)


EDIT: So it can be for example:

const rules = {
  foo: (values) => [[val => false, "error"]],
  bar: (values) => (values) => (values) => [[val => false, "error"]],
  baz: (values) => {
    x: [[val => false, "error"]],
    y: (yValues) => []
  }
}

Is that correct?


EDIT2: I still don't get it 😞

const data = {
  foo: 42
}

const rules = {
  foo: (value) => [[val => false, "error"]]
}

This is now allowed by TypeScript, but spected won't give any results for foo

Could you provide some simple examples without using ramda? 🤣 Then I can figure out the correct typings

busypeoples commented 5 years ago

I will check.

Also check this part, I think you need to wrap the object in () in your example.

baz: (values) => ({
    x: [[val => false, "error"]],
    y: (yValues) => []
  })
benneq commented 5 years ago

For objects: yes, true. I forgot that. This is working fine and gives the correct result:

const data = {
    foo: {
        bar: 42
    }
}

const rules = {
    foo: (value) => ({
        bar: [[val => false, "error"]]
    })
}

But what about this:

const data = {
    foo: 42
}

const rules = {
    foo: (value) => [[val => false, "error"]]
}

Should this do anything meaningful?


And when to use this:

const rules = {
    foo: (value) => (value) => (value) => ???
}

Sorry for being such a functional-currying-ramda noob

busypeoples commented 5 years ago

@benneq Thank you very much for the very valuable feedback. You shouldn't have to understand ramda to use the library. This is excellent feedback and shows some potential on how to improve the developer experience.

busypeoples commented 5 years ago

Regarding the ramda example from the beginning of this:

Not sure if a is valid, but b and c are the same, they expect an array of items and return an array of specifications for each item. Check this test https://github.com/25th-floor/spected/blob/master/test/index.js#L496

The input would be an array of users and the map function returns the rules for each user. I will rewrite the example for more clarification.

busypeoples commented 5 years ago

This is what calling b or c in your example would return:

[
    {
        "firstName": [
            [
                ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                 ...,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                 ...,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                ...,
                "capital letter missing"
            ]
        ]
    }
]

So, you could also write the following f.e.:

{users: 
[
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                ...,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                null,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                ...,
                "capital letter missing"
            ]
        ]
    }
]
}

or you could write it like this:

{
    b: input => input.map(val => [[(val) => false, 'msg']]),
    c: input => input.map(val => [[(val) => false, 'msg']]),
}

Does that help?

benneq commented 5 years ago

Ahh! That makes way more sense to me. Thank you!

So this is the same:

b: input => input.map(val => [[(val) => false, 'msg']])
c: Ramda.map([[(val) => false, 'msg']])

The signature is (value: INPUT) => ReadonlyArray<[Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>]>

And for objects it's: (value: INPUT) => Spec<INPUT, ROOTINPUT>


The last option would be something like:

{
  x: input => somethingElse => ???
}

Is this possible (or does it have any meaning) at all for spected?

busypeoples commented 5 years ago

I would neglect the last option, I don't think this could even work. Not sure what the result would be, but you need a very specific structure to make it work. The only valid use case for using a function in spected, is to validate against an unkown number of items, f.e. users, like in the example. If we can restrict any other possible options, this would be valuable actually.

benneq commented 5 years ago

Of course this can be restricted. TypeScript has an extremely mighty type system - in fact often overwhelming. You can have recursive structures, and you can have conditional types in generics, and lot's of other crazy stuff.

Maybe you should rethink this restriction (pointing to this: https://github.com/25th-floor/spected/issues/106#issuecomment-455181780 )

busypeoples commented 5 years ago

This is the only other use case where this approach makes sense:

const data = {
  foo: {bar: 1}
};

const rules = {
  foo: (value) => {
     return {bar: [[val => false, "error"]]}
  }
};

verify(rules, data); // => { foo: {bar: "error"} 
benneq commented 5 years ago

And for:

const data = {
  foo: ["a", "b", "c"]
}

too?

busypeoples commented 5 years ago
const data = {
  foo: ["a", "b", "c"]
}

This is like the users example, sure.

benneq commented 5 years ago

Let's clarify:

if INPUT is Array then allow:
 [[...]]
 (val) => ...

if INPUT is Object then allow:
 [[...]]
 (val) => ...
 { ... }

else allow:
 [[...]]
busypeoples commented 5 years ago

If the input is an object, we also have to return an object, if the input is an array, we have to return an array. The input/output have to match.


const data = {
  foo: {bar: 1}
};

const rules = {
  foo: (value) => {
     return {bar: [[val => false, "error"]]}
  }
};

const data2 = {
   users: [{id:1, name: "foo"}]
}

const rules2 = {
    users: input => input.map(val => [[(val) => false, 'msg']]),
}

Is the above definition helpful?

benneq commented 5 years ago

Thanks a lot! I guess that's it for the day... Now I have to see what TypeScript is capable of 😆 (or what I am capable of)

busypeoples commented 5 years ago

@benneq But no stress. It doesn't have to be explicit for now.

benneq commented 5 years ago

But I want to use it as soon as possible with TypeScript ;)

busypeoples commented 5 years ago

Sure, but you can define the type recursively for now, this will work.

((value: INPUT) =>  SpecValue<INPUT, ROOTINPUT>

Not tested, but this could be valid.

benneq commented 5 years ago

Yes this works. But again there are problems with type inference for tuples :(

const data = {
    b: ["a", "b"]
}

const rules: Spec<typeof data, typeof data> = {
    b: input => input.map(val => [[(val) => false, 'msg']])
    // here [[(val) => false, 'msg']] is not detected as tuple [Predicate, ErrorMsg],
    // but instead as Array<Predicate | ErrorMsg>
    // so you've got to write this:
    // input => input.map(val => [[(val) => false, 'msg']] as [Predicate<string, any>, ErrorMsg<string>][])
}

And the other problem is, that you are allowed to write (val) => (val) => (val) => ......

benneq commented 5 years ago

It's getting better and better!

I've renamed all the types and added some more:

I've removed the exports for Predicate and ErrorMsg.

The type inference for tuples seems now working correctly! No more need for as [...]

At the moment it is still allowed to write { x: (value) => ... } when x is neither array, nor object. But hopefully this this will work eventually when #104 and #106 are resolved 😃

Here are the newest typings:

declare function spected<ROOTINPUT, SPEC = SpecObject<ROOTINPUT, ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object)
    | ((value: INPUT, field: string) => any);

type SpecArrayElement<INPUT, ROOTINPUT> = [Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>];

export type SpecArray<INPUT, ROOTINPUT> = ReadonlyArray<SpecArrayElement<INPUT, ROOTINPUT>>;

export type SpecFunction<INPUT, ROOTINPUT> = INPUT extends ReadonlyArray<infer U>
    ? (value: INPUT) => ReadonlyArray<SpecArray<U, ROOTINPUT>>
    : (value: INPUT) => SpecObject<INPUT, ROOTINPUT>;

export type SpecObject<INPUT, ROOTINPUT> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>;

export type SpecValue<INPUT, ROOTINPUT> =
    | SpecArray<INPUT, ROOTINPUT>
    | SpecFunction<INPUT, ROOTINPUT>
    | SpecObject<INPUT, ROOTINPUT>;

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>};

export default spected;

And some working example:

const res = spected(
  {
    // input's type is inferred as "string[]"
    // val's type is inferred as "string"
    b: (input) => [[[(val) => val === 'b', 'err']]],
    // input's type is inferred as "{ d: number }"
    // val's type is inferred as "number"
    c: (input) => ({ d: [[(val) => val > 9000, 'err']] }),
    // input's type is inferred as "number[]"
    // val's type is inferred as "number"
    d: input => input.map(val => [[(val) => val > 9000, 'msg']])
  },
  {
    a: 42,
    b: ["a", "b"],
    c: {
        d: 42
    },
    d: [9000, 9001]
  }
);

When using ramda, it won't correctly infer the types 😞 But then you still can use this:

const data = {
    b: ["a", "b"],
}

const rules: SpecObject<typeof data, typeof data> = {
    b: R.map(() => [[(val) => val === 'b', 'err']]),
}

const res = spected(rules, data);

Next step: The Result type


EDIT: Pushed the current typings. Still waiting for merge: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/32173

benneq commented 5 years ago

I just played some more with the typings. It's driving me nuts! 🤣

First: VSCode sometimes takes really long to refresh the typings in my code after I changed the spected typings. Before I figured that out, I have rewritten everything a hundred times, because there was always something wrong. Sometimes really strange type inference appeared, where value: number was suddenly value: ROOTINPUT extends {}. And I was just WTF ?!

I guess the recursive stuff is taking some time to compute.

Now I always wait a few more seconds, and then change the code a bit, (un)comment some lines, and THEN check the typings...

Second: TypeScript has some really strange behavior.

  1. Somehow booleans aren't recognized within the typings. (see code below)
  2. Somehow SpecArray isn't allowed to be ReadonlyArray

The Code:

Typings:

declare function spected<ROOTINPUT, SPEC extends SpecValue<ROOTINPUT, ROOTINPUT> = SpecValue<ROOTINPUT, ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object)
    | ((value: INPUT, field: string) => any);

export type Spec<INPUT, ROOTINPUT = any> = [Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>];

export type SpecArray<INPUT, ROOTINPUT = any> = Array<Spec<INPUT, ROOTINPUT>>

export type SpecFunction<INPUT, ROOTINPUT = any> = INPUT extends ReadonlyArray<infer U>
    ? (value: INPUT) => ReadonlyArray<SpecArray<U, ROOTINPUT>>
    : INPUT extends {[key: string]: any}
        ? (value: INPUT) => SpecObject<INPUT, ROOTINPUT>
        : (value: INPUT) => SpecArray<INPUT, ROOTINPUT>;

export type SpecObject<INPUT, ROOTINPUT = any> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>

export type SpecValue<INPUT, ROOTINPUT = any> = INPUT extends ReadonlyArray<any>
    ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>
        : INPUT extends {[key: string]: any}
            ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT> | SpecObject<INPUT, ROOTINPUT>
            : SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>;

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>};

export default spected;

Tests:

const data = {
    notValidatedString: '',
    notValidatedArray: [0],
    notValidatedObject: {},
    str1: "",
    str2: "",
    number1: 0,
    number2: 0,
    boolean1: true,
    boolean2: true,
    array1: [0],
    array2: [0],
    array3: [0],
    emptyObj1: {},
    emptyObj2: {},
    emptyObj3: {},
    obj1: { foo: "bar" },
    obj2: { foo: "bar" },
    obj3: { foo: "bar" },
    obj4: { foo: "bar" },
    obj5: { foo: "bar" }
}

const res = spected<typeof data>(
    {
        str1: [[(value) => false, 'err']],
        str2: (value) => [[(value) => false, 'err']], // doesn't work until #104 #106
        number1: [[(value) => false, 'err']],
        number2: (value) => [[(value) => false, 'err']], // doesn't work until #104 #106
        // boolean1: [[(value) => false, 'err']], // value is of type any...
        // boolean2: (value) => [[(value) => false, 'err']], // value is of type any...
        array1: [[(value) => false, 'err']],
        array2: (value) => [[[(value) => false, 'err']]],
        array3: (value) => value.map(elem => [[(value) => false, 'err']]),
        emptyObj1: [[(value) => false, 'err']],
        emptyObj2: (value) => ({}),
        emptyObj3: {},
        obj1: [[(value) => false, 'err']],
        obj2: (value) => ({}),
        obj3: (value) => ({ foo: [[(value) => false, 'err']] }),
        obj4: {},
        obj5: { foo: [[(value) => false, 'err']] },
    },
    data
);

For the boolean issue, you can still write it like this and it works:

const data = {
    boolean1: true,
    boolean2: true
}

const res = spected<typeof data>(
    {
        boolean1: [[(value) => true, 'err']] as SpecArray<boolean, typeof data>,
        boolean2: ((value: boolean) => [[(value: boolean) => true, 'err']]) as SpecFunction<boolean, typeof data>,
    },
    data
);

Tests for the future (#104 #106) (already supported by the typings):

spected([[(value) => false, 'err']], "");
spected((value) => [[(value) => false, 'err']], "");
spected([[(value) => false, 'err']], 0);
spected((value) => [[(value) => false, 'err']], 0);
spected([[(value) => false, 'err']], true);
spected((value) => [[(value) => false, 'err']], true);
spected([[(value) => false, 'err']], [0]);
spected((value) => [[[(value) => false, 'err']]], [0]);
spected((value) => value.map(elem => [[(value) => false, 'err']]), [0]);
spected([[(value) => false, 'err']], {});
spected((value) => ({}), {});
spected({}, {});
spected([[(value) => false, 'err']], { foo: "bar" });
spected((value) => ({}), { foo: "bar" });
spected((value) => ({ foo: [[(value) => false, 'err']] }), { foo: "bar" });
spected({}, { foo: "bar" });
spected<{ foo: string }>({ foo: [[(value) => false, 'err']] }, { foo: "bar" }); // type inference not working
// must be explicitly provided
benneq commented 5 years ago

Thanks to jack-williams we now have working booleans! https://github.com/Microsoft/TypeScript/issues/29477

I didn't know about this "distributive conditional types" in TypeScript. Really weird stuff 😆

declare function spected<ROOTINPUT, SPEC extends SpecValue<ROOTINPUT, ROOTINPUT> = SpecValue<ROOTINPUT, ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object)
    | ((value: INPUT, field: string) => any);

export type Spec<INPUT, ROOTINPUT = any> = [Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>];

export type SpecArray<INPUT, ROOTINPUT = any> = Array<Spec<INPUT, ROOTINPUT>>

export type SpecFunction<INPUT, ROOTINPUT = any> = [INPUT] extends [ReadonlyArray<infer U>]
    ? (value: INPUT) => ReadonlyArray<SpecArray<U, ROOTINPUT>>
    : [INPUT] extends [object]
        ? (value: INPUT) => SpecObject<INPUT, ROOTINPUT>
        : (value: INPUT) => SpecArray<INPUT, ROOTINPUT>;

export type SpecObject<INPUT, ROOTINPUT = any> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>

export type SpecValue<INPUT, ROOTINPUT = any> = [INPUT] extends [ReadonlyArray<any>]
    ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>
        : [INPUT] extends [object]
            ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT> | SpecObject<INPUT, ROOTINPUT>
            : SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>;

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>};