ealush / vest

Vest βœ… Declarative validations framework

Home Page:https://vestjs.dev/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

better TS support

Coobaha opened this issue Β· comments

Hi! @ealush πŸ‘‹

Firstly, thanks for your work on this project! πŸ™‚

Locally we are using patched version of vest, so that we can have strict fieldNames and groups. Here is a rough patch which works fine in our project, but probably not very user friendly API and can be improved

Patch
diff --git a/node_modules/vest/types/vest.d.ts b/node_modules/vest/types/vest.d.ts
index 76acf17..535b41d 100644
--- a/node_modules/vest/types/vest.d.ts
+++ b/node_modules/vest/types/vest.d.ts
@@ -1,13 +1,13 @@
 import { enforce } from 'n4s';
 import { CB, nestedArray } from "vest-utils";
 import { UseState } from "vast";
-type SuiteSummary = {
-    groups: Groups;
+type SuiteSummary<Group> = {
+    groups: Groups<Group>;
     tests: Tests;
     valid: boolean;
 } & SummaryBase;
 type GroupTestSummary = SingleTestSummary;
-type Groups = Record<string, Group>;
+type Groups<GroupKey> = Record<Extract<GroupKey, string>, Group>;
 type Group = Record<string, GroupTestSummary>;
 type Tests = Record<string, SingleTestSummary>;
 type SingleTestSummary = SummaryBase & {
@@ -20,16 +20,16 @@ type SummaryBase = {
     warnCount: number;
     testCount: number;
 };
-type FailureMessages = Record<string, string[]>;
+type FailureMessages<Key> = Record<Extract<Key, string>, string[]>;
 declare enum TestSeverity {
     Error = "error",
     Warning = "warning"
 }
-declare class VestTest {
-    fieldName: string;
+declare class VestTest<Field, Group> {
+    fieldName: Field;
     testFn: TestFn;
     asyncTest?: AsyncTest;
-    groupName?: string;
+    groupName?: Group;
     message?: string;
     key?: null | string;
     id: string;
@@ -72,47 +72,47 @@ type TestResult = AsyncTest | boolean | void;
 type TestFn = () => TestResult;
 type KStatus = "UNTESTED" | "SKIPPED" | "FAILED" | "WARNING" | "PASSING" | "PENDING" | "CANCELED" | "OMITTED";
 // eslint-disable-next-line max-lines-per-function, max-statements
-declare function suiteSelectors(summary: SuiteSummary): SuiteSelectors;
-interface SuiteSelectors {
-    getErrors(fieldName: string): string[];
-    getErrors(): FailureMessages;
-    getWarnings(): FailureMessages;
-    getWarnings(fieldName: string): string[];
-    getErrorsByGroup(groupName: string, fieldName: string): string[];
-    getErrorsByGroup(groupName: string): FailureMessages;
-    getWarningsByGroup(groupName: string): FailureMessages;
-    getWarningsByGroup(groupName: string, fieldName: string): string[];
-    hasErrors(fieldName?: string): boolean;
-    hasWarnings(fieldName?: string): boolean;
-    hasErrorsByGroup(groupName: string, fieldName?: string): boolean;
-    hasWarningsByGroup(groupName: string, fieldName?: string): boolean;
-    isValid(fieldName?: string): boolean;
-    isValidByGroup(groupName: string, fieldName?: string): boolean;
+declare function suiteSelectors<Field, Group>(summary: SuiteSummary<Group>): SuiteSelectors<Field, Group>;
+interface SuiteSelectors<Field, Group> {
+    getErrors(fieldName: Field): string[];
+    getErrors(): FailureMessages<Field>;
+    getWarnings(): FailureMessages<Field>;
+    getWarnings(fieldName: Field): string[];
+    getErrorsByGroup(groupName: Group, fieldName: Field): string[];
+    getErrorsByGroup(groupName: Group): FailureMessages<Group>;
+    getWarningsByGroup(groupName: Group): FailureMessages<Group>;
+    getWarningsByGroup(groupName: Group, fieldName: Field): string[];
+    hasErrors(fieldName?: Field): boolean;
+    hasWarnings(fieldName?: Field): boolean;
+    hasErrorsByGroup(groupName: Group, fieldName?: Field): boolean;
+    hasWarningsByGroup(groupName: Group, fieldName?: Field): boolean;
+    isValid(fieldName?: Field): boolean;
+    isValidByGroup(groupName: Group, fieldName?: Field): boolean;
 }
-type SuiteResult = SuiteSummary & SuiteSelectors & {
+type SuiteResult<Field, Group> = SuiteSummary<Group> & SuiteSelectors<Field, Group> & {
     suiteName: SuiteName;
 };
-type SuiteRunResult = SuiteResult & {
-    done: Done;
+type SuiteRunResult<Field, Group> = SuiteResult<Field, Group> & {
+    done: Done<Field, Group>;
 };
-interface Done {
+interface Done<Field, Group> {
     (...args: [
-        cb: (res: SuiteResult) => void
-    ]): SuiteRunResult;
+        cb: (res: SuiteResult<Field, Group>) => void
+    ]): SuiteRunResult<Field, Group>;
     (...args: [
         fieldName: string,
-        cb: (res: SuiteResult) => void
-    ]): SuiteRunResult;
+        cb: (res: SuiteResult<Field, Group>) => void
+    ]): SuiteRunResult<Field, Group>;
 }
-type CreateProperties = {
-    get: () => SuiteResult;
+type CreateProperties<Field, Group> = {
+    get: () => SuiteResult<Field, Group>;
     reset: () => void;
-    resetField: (fieldName: string) => void;
-    remove: (fieldName: string) => void;
+    resetField: (fieldName: Field) => void;
+    remove: (fieldName: Field) => void;
 };
-type Suite<T extends CB> = {
-    (...args: Parameters<T>): SuiteRunResult;
-} & CreateProperties;
+type Suite<Field, Group, T extends CB> = {
+    (...args: Parameters<T>): SuiteRunResult<Field, Group>;
+} & CreateProperties<Field, Group>;
 /**
  * Creates a new validation suite
  *
@@ -124,8 +124,8 @@ type Suite<T extends CB> = {
  *  });
  * });
  */
-declare function create<T extends CB>(suiteName: SuiteName, suiteCallback: T): Suite<T>;
-declare function create<T extends CB>(suiteCallback: T): Suite<T>;
+declare function create<Field, Group, T extends CB>(suiteName: SuiteName, suiteCallback: T): Suite<Field, Group, T>;
+declare function create<Field, Group, T extends CB>(suiteCallback: T): Suite<Field, Group, T>;
 type SuiteName = string | void;
 type IsolateCursor = {
     current: () => number;
@@ -139,13 +139,13 @@ declare enum IsolateTypes {
     OMIT_WHEN = 4,
     GROUP = 5
 }
-type IsolateKeys = {
-    current: Record<string, VestTest>;
-    prev: Record<string, VestTest>;
+type IsolateKeys<Field, Group> = {
+    current: Record<string, VestTest<Field, Group>>;
+    prev: Record<string, VestTest<Field, Group>>;
 };
-type Isolate = {
+type Isolate<Field, Group> = {
     type: IsolateTypes;
-    keys: IsolateKeys;
+    keys: IsolateKeys<Field, Group>;
     path: number[];
     cursor: IsolateCursor;
 };
@@ -164,8 +164,8 @@ declare enum Modes {
  *  username: () => allowUsernameEmpty,
  * });
  */
-declare function optional(optionals: OptionalsInput): void;
-type OptionalsInput = string | string[] | OptionalsObject;
+declare function optional<Field>(optionals: OptionalsInput<Field>): void;
+type OptionalsInput<Field> = Field | Field[] | OptionalsObject;
 type OptionalsObject = Record<string, (() => boolean) | boolean>;
 type ImmediateOptionalFieldDeclaration = {
     type: OptionalFieldTypes.Immediate;
@@ -182,33 +182,33 @@ declare enum OptionalFieldTypes {
     Immediate = 0,
     Delayed = 1
 }
-type StateRef = {
+type StateRef<Field, Group> = {
     optionalFields: UseState<OptionalFields>;
     suiteId: UseState<string>;
     suiteName: UseState<SuiteName>;
-    testCallbacks: UseState<TestCallbacks>;
-    testObjects: UseState<TestObjects>;
+    testCallbacks: UseState<TestCallbacks<Field, Group>>;
+    testObjects: UseState<TestObjects<Field, Group>>;
 };
 type OptionalFields = Record<string, OptionalFieldDeclaration>;
-type TestCallbacks = {
-    fieldCallbacks: Record<string, Array<(res: SuiteResult) => void>>;
-    doneCallbacks: Array<(res: SuiteResult) => void>;
+type TestCallbacks<Field, Group> = {
+    fieldCallbacks: Record<string, Array<(res: SuiteResult<Field, Group>) => void>>;
+    doneCallbacks: Array<(res: SuiteResult<Field, Group>) => void>;
 };
-type TestObjects = {
-    prev: VestTests;
-    current: VestTests;
+type TestObjects<Field, Group> = {
+    prev: VestTests<Field, Group>;
+    current: VestTests<Field, Group>;
 };
-type VestTests = nestedArray.NestedArray<VestTest>;
-declare const _default: import("context").CtxCascadeApi<CTXType>;
-type CTXType = {
-    isolate: Isolate;
-    stateRef?: StateRef;
+type VestTests<Field, Group> = nestedArray.NestedArray<VestTest<Field, Group>>;
+declare const _default: import("context").CtxCascadeApi<CTXType<string, string>>;
+type CTXType<Field, Group> = {
+    isolate: Isolate<Field, Group>;
+    stateRef?: StateRef<Field, Group>;
     exclusion: {
         tests: Record<string, boolean>;
         groups: Record<string, boolean>;
     };
     inclusion: Record<string, boolean | (() => boolean)>;
-    currentTest?: VestTest;
+    currentTest?: VestTest<Field, Group>;
     groupName?: string;
     skipped?: boolean;
     omitted?: boolean;
@@ -237,7 +237,7 @@ declare const context: typeof _default;
  * })
  */
 declare function each<T>(list: T[], callback: (arg: T, index: number) => void): void;
-type ExclusionItem = string | string[] | undefined;
+type ExclusionItem<Item> = Item | Item[] | undefined;
 /**
  * Adds a field or a list of fields into the inclusion list
  *
@@ -245,9 +245,9 @@ type ExclusionItem = string | string[] | undefined;
  *
  * only('username');
  */
-declare function only(item: ExclusionItem): void;
+declare function only<Field>(item: ExclusionItem<Field>): void;
 declare namespace only {
-    var group: (item: ExclusionItem) => void;
+    var group: <Group>(item: ExclusionItem<Group>) => void;
 }
 /**
  * Adds a field or a list of fields into the exclusion list
@@ -256,9 +256,9 @@ declare namespace only {
  *
  * skip('username');
  */
-declare function skip(item: ExclusionItem): void;
+declare function skip<Field>(item: ExclusionItem<Field>): void;
 declare namespace skip {
-    var group: (item: ExclusionItem) => void;
+    var group: <Group>(item: ExclusionItem<Group>) => void;
 }
 /**
  * Runs tests within a group so that they can be controlled or queried separately.
@@ -269,9 +269,9 @@ declare namespace skip {
  *  // Tests go here
  * });
  */
-declare function group(groupName: string, tests: () => void): void;
-declare function include(fieldName: string): {
-    when: (condition: string | boolean | ((draft: SuiteResult) => boolean)) => void;
+declare function group<Group>(groupName: Group, tests: () => void): void;
+declare function include<Field, Group, WhenFields = Field>(fieldName: Field): {
+    when: (condition: WhenFields | boolean | ((draft: SuiteResult<Field, Group>) => boolean)) => void;
 };
 /**
  * Sets the suite to "eager" (fail fast) mode.
@@ -302,7 +302,7 @@ declare function eager(): void;
  *  test('username', 'User already taken', async () => await doesUserExist(username)
  * });
  */
-declare function omitWhen(conditional: boolean | ((draft: SuiteResult) => boolean), callback: CB): void;
+declare function omitWhen<Field, Group>(conditional: boolean | ((draft: SuiteResult<Field, Group>) => boolean), callback: CB): void;
 /**
  * Conditionally skips running tests within the callback.
  *
@@ -312,11 +312,26 @@ declare function omitWhen(conditional: boolean | ((draft: SuiteResult) => boolea
  *  test('username', 'User already taken', async () => await doesUserExist(username)
  * });
  */
-declare function skipWhen(conditional: boolean | ((draft: SuiteResult) => boolean), callback: CB): void;
-declare function testBase(fieldName: string, message: string, cb: TestFn): VestTest;
-declare function testBase(fieldName: string, cb: TestFn): VestTest;
-declare function testBase(fieldName: string, message: string, cb: TestFn, key: string): VestTest;
-declare function testBase(fieldName: string, cb: TestFn, key: string): VestTest;
+declare function skipWhen<Field, Group>(conditional: boolean | ((draft: SuiteResult<Field, Group>) => boolean), callback: CB): void;
+declare function testBase<Field, Group>(fieldName: Field, message: string, cb: TestFn): VestTest<Field, Group>;
+declare function testBase<Field, Group>(fieldName: Field, cb: TestFn): VestTest<Field, Group>;
+declare function testBase<Field, Group>(fieldName: Field, message: string, cb: TestFn, key: string): VestTest<Field, Group>;
+declare function testBase<Field, Group>(fieldName: Field, cb: TestFn, key: string): VestTest<Field, Group>;
+
+export type Test<
+  Field,
+  Group,
+> = typeof testBase<Field, Group> & {
+  memo: {
+    (fieldName: Field, test: TestFn, deps: unknown[]): VestTest<Field, Group>
+    (
+      fieldName: Field,
+      message: string,
+      test: TestFn,
+      deps: unknown[],
+    ): VestTest<Field, Group>
+  }
+}
 /**
  * Represents a single case in a validation suite.
  *
@@ -326,12 +341,7 @@ declare function testBase(fieldName: string, cb: TestFn, key: string): VestTest;
  *  enforce(data.username).isNotBlank();
  * });
  */
-declare const test: typeof testBase & {
-    memo: {
-        (fieldName: string, test: TestFn, deps: unknown[]): VestTest;
-        (fieldName: string, message: string, test: TestFn, deps: unknown[]): VestTest;
-    };
-};
+declare const test: Test<unknown, unknown>;
 /**
  * Sets a running test to warn only mode.
  */

usage example (again it is not that user friendly, but works for local project that we have with vest):

import * as _Vest from 'vest'

type V<F, G, S> = Omit<typeof _Vest, 'test' | 'include' | 'only' | 'create'> & {
  include: typeof _Vest.include<F, G>
  test: _Vest.Test<F, G>
  only: typeof _Vest.only<Fields>
  create: typeof _Vest.create<F, G, (state: S, currentField?: F) => unknown>
  SuiteResult: _Vest.SuiteResult<F, G>
}

const V = _Vest as V<'username' | 'password', never, State>

V.test('username')

What do you think? This caught lots of bugs for us, cause of typos or wrong strings usage

Hey @Coobaha, so glad that you're enjoying Vest. Also, I'm super impressed with the changes you've made to make Vest suit your needs.

I completely see the value in your proposal, and I'm thinking how we can do it without making it a breaking change.

The main problem is that Vest (intentionally) exposes individual exports, so your type of generics won't work with the current version of vest out of the box.

I think I can save you all the patching, by adding the support directly on these functions so no patching is required, and you will only have to do the last bit of actually using these generics. Would you like to collaborate with me on this?

@ealush thanks for your kind words :) I am definitely keen to improve vest's TS story! I've been thinking about factory but this probably defeats tree shaking, so i've opted in for a pure type level augment in our project.

My suggestion is that we modify all the existing builtin vest types to support these same generics that you provided, that you won't have to patch vest for every version being released. It won't change anything for existing consumers, but if they want to make the types more explicit, it will be available to them.

function test<T extends string>(...) {...}

So basically what I did in the patch? My original patched version was exactly Field extends string everywhere but i've removed string so I can force it to be unknown/never

I think with this approach important thing to get done right is so that arbitrary string wont be allowed. At least this was my goal in patch. Do you want me to open an initial PR so we will iterate there?

Exactly. This could probably be done gradually. I will create a draft PR sometime in the next couple of days, we can discuss the solution further there.

Hey, I am sorry that its been so long since I updated on this one.

Apparently, it is not very easy to implement and propagate all the types throughout Vest, so instead, I took it as a separate track inside of Vest@5 that is in the works.

So far it seems to be looking good and working great, and the gist of it is this:

All of Vest's methods accept a generic with the possible field names and group names:

type FieldNames = "username" | "password" | "confirm";
type GroupNames = "sign_in" | "change_password";

vest.create<CallBackType, FieldNames, GroupNames>(...);
test<FieldNames, GroupNames>(...);
only<FieldNames, GroupNames>(...);
skip<FieldNames, GroupNames>(...);
include<FieldNames, GroupNames>(...);
skipWhen<FieldNames, GroupNames>(...);
group<GroupNames, FieldNames>(...); // note that the order is flipped here
...

// Then the result object is easily typed:
// the following will only accept the allowed field names
suite.get().hasErrors(...);
suite.get().hasWarnings(...);
suite.get().hasErrorsByGroup(...);
...

But this is very tedious (and also ugly) to type every single function with its accepted fieldnames and groups, so instead, I added the option to get these function typed by the suite itself like this:

const suite = vest.create<CallBackType, FieldNames, GroupNames>(() => {
  test(...);

  group(...);
});

const {
  test,
  only,
  group,
  ...
} = suite;

In this way, these functions will come "pre-typed" only to the field and group names the suite was typed with, ensuring better type safety.

It took several changes to get right, so there's no specific commit I can point to, but here are a few of them:

cd03372
1381350
9a3fa37
6cce662

Hy @Coobaha, in addition to adding it to V5, I documented it here.

@ealush Hey! Great to see TS support landing in v5! πŸŽ‰ :)

question: Why do we pass Callback type instead of inferring its type and defaulting to unknown? It will be a better public api imho

const suite = vest.create<Fields, Groups>((state: SomeState, someExtraData: number) => {
  //   ?^ suite will correctly infer cb type 
});
const suite = vest.create<Fields, Groups>((state) => {
  //   ?^ state is unknown here
});

Hey @Coobaha, I think I agree. I kept the Callback generic as the first property, because it was the only one that existed in V4.
Since it can be inferred, unlike the others, it does indeed make more sense that it will be optional. Not sure about defaulting to unknown vs a default callback type, though. I will experiment with both.

Either way, great feedback!

Done, @Coobaha
This indeed feels much cleaner. Putting CB as the first generic argument made it feel unnatural. Moving it to the end allowed me to even remove a few unneeded types in my unit tests.

  • You can see the code change here
  • The new docs are already live here

And the "next" tag is being published as we speak. Should finish its release cycle in 10 minutes or so. Finished.

vest@5.0.0-next-470211

@Coobaha, V5 is out with all these changes. Would love to hear from you if this is useful for you and if there are any issues. Closing for now.

@ealush Kudos for bringing TS support to v5. Unfortunately, I was affected by layoffs at RapidAPI and can't migrate the project and verify changes as I no longer have access to it.