Skip to content

Commit

Permalink
feat(core): form schema creator (#8)
Browse files Browse the repository at this point in the history
* feat(core): setup code structure and basic types

* chore: tsconfig 'include' fix

* feat(core): field decoders implementation

* refactor(core): cleaner FieldDecoder type

* feat(core): create form schema - specification

* fix(core): decoders typing fix

* feat(core): create form schema - implementation

* fix(core): disallow using choice decoder with no options

- make surre error at compile time is thrown by TS for empty choice field
- throw error at run time too
- fix JSDoc not appearing

* refactor(core): hide implementation details of FieldDecoder type

* fix: review fixes

- change array path format
- choice decoder: better signature
- choice decoder: remvoe runtime input assertion
- choice decoder: more performant implementation

Co-authored-by: Mikołaj Klaman <mklaman@virtuslab.com>
  • Loading branch information
mixvar and Mikołaj Klaman authored Sep 27, 2020
1 parent f37884a commit 8394379
Show file tree
Hide file tree
Showing 34 changed files with 1,388 additions and 8 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@typescript-eslint/eslint-plugin": "^4.1.1",
"@typescript-eslint/parser": "^4.1.1",
"add": "^2.0.6",
"conditional-type-checks": "^1.0.5",
"eslint": "^7.9.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.22.0",
Expand Down
254 changes: 254 additions & 0 deletions src/core/builders/create-form-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { assert, IsExact } from "conditional-type-checks";

import { FormSchema } from "../types/form-schema";
import { impl } from "../types/type-mapper-util";

import { createFormSchema } from "./create-form-schema";

describe("createFormSchema", () => {
it("creates FormSchema type based on field decoders with default error type", () => {
const Schema = createFormSchema(fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}));

type Actual = typeof Schema;
type Expected = FormSchema<
{
string: string;
choice: "A" | "B" | "C";
num: number | "";
bool: boolean;
arrayString: string[];
arrayChoice: Array<"a" | "b" | "c">;
arrayArrayString: string[][];
instance: Date | null;
},
never
>;

assert<IsExact<Actual, Expected>>(true);
});

it("creates FormSchema type based on field decoders with custom error type", () => {
const Schema = createFormSchema(
fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}),
error => error<"ERR_1" | "ERR_2">()
);

type Actual = typeof Schema;
type Expected = FormSchema<
{
string: string;
choice: "A" | "B" | "C";
num: number | "";
bool: boolean;
arrayString: string[];
arrayChoice: Array<"a" | "b" | "c">;
arrayArrayString: string[][];
instance: Date | null;
},
"ERR_1" | "ERR_2"
>;

assert<IsExact<Actual, Expected>>(true);
});

it("creates empty schema object when no fields are specified", () => {
const Schema = createFormSchema(() => ({}));

expect(Object.keys(Schema)).toEqual([]);
});

it("creates schema object with keys for every field", () => {
const Schema = createFormSchema(fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}));

expect(Object.keys(Schema)).toEqual([
"string",
"choice",
"num",
"bool",
"arrayString",
"arrayChoice",
"arrayArrayString",
"instance",
]);
});

it("creates schema object with paths for every field", () => {
const Schema = createFormSchema(fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}));

expect(Schema).toEqual({
string: expect.objectContaining({ path: "string" }),
choice: expect.objectContaining({ path: "choice" }),
num: expect.objectContaining({ path: "num" }),
bool: expect.objectContaining({ path: "bool" }),
arrayString: expect.objectContaining({
root: expect.objectContaining({ path: "arrayString" }),
}),
arrayChoice: expect.objectContaining({
root: expect.objectContaining({ path: "arrayChoice" }),
}),
arrayArrayString: expect.objectContaining({
root: expect.objectContaining({ path: "arrayArrayString" }),
}),
instance: expect.objectContaining({ path: "instance" }),
});
});

it("creates field descriptor for string field", () => {
const Schema = createFormSchema(fields => ({
theString: fields.string(),
}));

const descriptor = impl(Schema.theString);

expect(descriptor.fieldType).toBe("string");
expect(descriptor.path).toBe("theString");
expect(descriptor.init()).toBe("");
expect(descriptor.decode("foo").ok).toBe(true);
expect(descriptor.decode(42).ok).toBe(false);
});

it("creates field descriptor for choice field", () => {
const Schema = createFormSchema(fields => ({
theChoice: fields.choice("A", "B", "C"),
}));

const descriptor = impl(Schema.theChoice);

expect(descriptor.fieldType).toBe("choice");
expect(descriptor.path).toBe("theChoice");
expect(descriptor.options).toEqual(["A", "B", "C"]);
expect(descriptor.init()).toBe("A");
expect(descriptor.decode("C").ok).toBe(true);
expect(descriptor.decode("foo").ok).toBe(false);
});

it("does not allow creating schema with empty choice field", () => {
createFormSchema(fields => ({
// @ts-expect-error
theChoice: fields.choice(),
}));
});

it("creates field descriptor for number field", () => {
const Schema = createFormSchema(fields => ({
theNumber: fields.number(),
}));

const descriptor = impl(Schema.theNumber);

expect(descriptor.fieldType).toBe("number");
expect(descriptor.path).toBe("theNumber");
expect(descriptor.init()).toBe("");
expect(descriptor.decode(666).ok).toBe(true);
expect(descriptor.decode("42").ok).toBe(false);
});

it("creates field descriptor for bool field", () => {
const Schema = createFormSchema(fields => ({
theBoolean: fields.bool(),
}));

const descriptor = impl(Schema.theBoolean);

expect(descriptor.fieldType).toBe("bool");
expect(descriptor.path).toBe("theBoolean");
expect(descriptor.init()).toBe(false);
expect(descriptor.decode(true).ok).toBe(true);
expect(descriptor.decode("true").ok).toBe(false);
});

it("creates field descriptor for array field", () => {
const Schema = createFormSchema(fields => ({
theArray: fields.array(fields.string()),
}));

const rootDescriptor = impl(Schema.theArray.root);

expect(rootDescriptor.fieldType).toBe("array");
expect(rootDescriptor.path).toBe("theArray");
expect(rootDescriptor.init()).toEqual([]);
expect(rootDescriptor.decode(["foo", "bar"]).ok).toBe(true);
expect(rootDescriptor.decode("foo").ok).toBe(false);
expect(rootDescriptor.inner.fieldType).toBe("string");

const elementDescriptor = impl(Schema.theArray.nth(42));

expect(elementDescriptor.fieldType).toBe("string");
expect(elementDescriptor.path).toBe("theArray[42]");
expect(elementDescriptor.init()).toEqual("");
expect(elementDescriptor.decode("foo").ok).toBe(true);
expect(elementDescriptor.decode(["foo", "bar"]).ok).toBe(false);
});

it("creates field descriptor for nested array field", () => {
const Schema = createFormSchema(fields => ({
theArray: fields.array(fields.array(fields.number())),
}));

const rootDescriptor = impl(Schema.theArray.root);
expect(rootDescriptor.fieldType).toBe("array");
expect(rootDescriptor.path).toBe("theArray");

const elementDescriptor = impl(Schema.theArray.nth(42).root);
expect(elementDescriptor.fieldType).toBe("array");
expect(elementDescriptor.path).toBe("theArray[42]");

const elementElementDescriptor = impl(Schema.theArray.nth(42).nth(666));
expect(elementElementDescriptor.fieldType).toBe("number");
expect(elementElementDescriptor.path).toBe("theArray[42][666]");
});

it("creates field descriptor for instanceOf field", () => {
class MyClass {
constructor(public foo: string) {}
}

const Schema = createFormSchema(fields => ({
theClass: fields.instanceOf(MyClass),
}));

const descriptor = impl(Schema.theClass);

expect(descriptor.fieldType).toBe("class");
expect(descriptor.path).toBe("theClass");
expect(descriptor.init()).toBe(null);
expect(descriptor.decode(new MyClass("42")).ok).toBe(true);
expect(descriptor.decode({ foo: "42" }).ok).toBe(false);
});
});
94 changes: 94 additions & 0 deletions src/core/builders/create-form-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { assertNever } from "../../utils";
import { bool, string, number, choice, array, instanceOf } from "../decoders";
import { FieldDecoder, _FieldDecoderImpl } from "../types/field-decoder";
import { _FieldDescriptorImpl } from "../types/field-descriptor";
import { FormSchema } from "../types/form-schema";
import { impl } from "../types/type-mapper-util";

const Decoders = {
bool,
string,
number,
choice,
array,
instanceOf,
};

type BuilderFn<V> = (
fields: typeof Decoders
) => { [K in keyof V]: FieldDecoder<V[K]> };

type ErrorsMarker<Err> = (errors: <Err>() => Err) => Err;

// loose typing for helping with internal impl, as working with generic target types is impossible
type _FormSchemaApprox_ = Record<string, _DescriptorApprox_>;

type _DescriptorApprox_ =
| _FieldDescriptorImpl<any>
| {
readonly root: _FieldDescriptorImpl<any>;
readonly nth: (index: number) => _DescriptorApprox_;
};

/**
* Define shape of the form values and type of validation errors.
* This is used not only for compile-time type-safety but also for runtime validation of form values.
* The schema can be defined top-level, so that it can be exported to nested Form components for usage together with `useField` hook.
*
* @returns
* FormSchema - used to interact with Formts API and point to specific form fields
*
* @example
* ```
* const Schema = createForm.schema(
* fields => ({
* name: fields.string(),
* age: fields.number(),
* }),
* errors => errors<string>()
* );
* ```
*/
export const createFormSchema = <Values extends object, Err = never>(
fields: BuilderFn<Values>,
_errors?: ErrorsMarker<Err>
): FormSchema<Values, Err> => {
const decodersMap = fields(Decoders);

return Object.keys(decodersMap).reduce<_FormSchemaApprox_>((schema, key) => {
const decoder = decodersMap[key as keyof Values];
schema[key] = createFieldDescriptor(
impl(decoder) as _FieldDecoderImpl<any>,
key
);
return schema;
}, {}) as any;
};

const createFieldDescriptor = (
decoder: _FieldDecoderImpl<any>,
path: string
): _DescriptorApprox_ => {
switch (decoder.fieldType) {
case "bool":
case "number":
case "string":
case "class":
case "choice":
return { ...decoder, path };

case "array": {
const root = { ...decoder, path };
const nth = (i: number) =>
createFieldDescriptor(
decoder.inner as _FieldDecoderImpl<any>,
`${path}[${i}]`
);

return { root, nth };
}

default:
return assertNever(decoder.fieldType);
}
};
10 changes: 10 additions & 0 deletions src/core/builders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createFormSchema } from "./create-form-schema";

/**
* container for form builder functions
*/
export const createForm = {
schema: createFormSchema,
// validator: createFormValidator, // TODO
// transformer: createFormTransformer, // TODO
};
Loading

0 comments on commit 8394379

Please sign in to comment.