-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): form schema creator (#8)
* 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
Showing
34 changed files
with
1,388 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
Oops, something went wrong.