Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ts simpler 2 #1134

Merged
merged 34 commits into from
Dec 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .babelrc.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
module.exports = (api) => ({
presets: [
[
'jason',
'babel-preset-jason/esm',
api.env() !== 'test'
? {
ignoreBrowserslistConfig: true,
modules: api.env() === 'modules' ? false : 'commonjs',
modules: api.env() === 'esm' ? false : 'commonjs',
}
: {
target: 'node',

// debug: true,
targets: { node: 'current' },
},
],
'@babel/preset-typescript',
],
plugins: [
'@babel/plugin-proposal-logical-assignment-operators',
api.env() === 'modules' && [
'transform-rename-import',
{
Expand Down
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.eslintrc
.eslintrc.js
417 changes: 115 additions & 302 deletions README.md

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions docs/extending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Extending Schema

For simple cases where you want to reuse common schema configurations, creating
and passing around instances works great and is automatically typed correctly

```js
import * as yup from 'yup';

const requiredString = yup.string().required().default('');

const momentDate = (parseFormats = ['MMM dd, yyy']) =>
yup.date().transform(function (value, originalValue) {
if (this.isType(value)) return value;

// the default coercion transform failed so let's try it with Moment instead
value = Moment(originalValue, parseFormats);
return value.isValid() ? value.toDate() : yup.date.INVALID_DATE;
});

export { momentDate, requiredString };
```

Schema are immutable so each can be configured further without changing the original.

## Extending Schema with new methods

`yup` provides a `addMethod()` utility for extending built-in schema:

```js
function parseDateFromFormats(formats, parseStrict) {
return this.transform(function (value, originalValue) {
if (this.isType(value)) return value;

value = Moment(originalValue, formats, parseStrict);

return value.isValid() ? value.toDate() : yup.date.INVALID_DATE;
});
}

yup.addMethod(yup.date, 'format', parseDateFromFormats);
```

Note that `addMethod` isn't really magic, it mutates the prototype of the passed in schema.

> Note: if you are using TypeScript you also need to adjust the class or interface
> see the [typescript](./typescript) docs for details.

## Creating new Schema types

If you're use case calls for creating an entirely new type. inheriting from
and existing schema class may be best: Generally you should not inheriting from
the abstract `Schema` unless you know what you are doing. The other types are fair game though.

You should keep in mind some basic guidelines when extending schemas:

- never mutate an existing schema, always `clone()` and then mutate the new one before returning it.
Built-in methods like `test` and `transform` take care of this for you, so you can safely use them (see below) without worrying

- transforms should never mutate the `value` passed in, and should return an invalid object when one exists
(`NaN`, `InvalidDate`, etc) instead of `null` for bad values.

- by the time validations run the `value` is guaranteed to be the correct type, however it still may
be `null` or `undefined`

```js
import { DateSchema } from 'yup';

class MomentDateSchema extends DateSchema {
static create() {
return MomentDateSchema();
}

constructor() {
super();
this._validFormats = [];

this.withMutation(() => {
this.transform(function (value, originalvalue) {
if (this.isType(value))
// we have a valid value
return value;
return Moment(originalValue, this._validFormats, true);
});
});
}

_typeCheck(value) {
return (
super._typeCheck(value) || (moment.isMoment(value) && value.isValid())
);
}

format(formats) {
if (!formats) throw new Error('must enter a valid format');
let next = this.clone();
next._validFormats = {}.concat(formats);
}
}

let schema = new MomentDateSchema();

schema.format('YYYY-MM-DD').cast('It is 2012-05-25'); // => Fri May 25 2012 00:00:00 GMT-0400 (Eastern Daylight Time)
```
100 changes: 100 additions & 0 deletions docs/typescript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
## TypeScript Support

`yup` comes with robust typescript support! However, because of how dynamic `yup` is
not everything can be statically typed safely, but for most cases it's "Good Enough".

Not that `yup` schema actually produce _two_ different types: the result of casting an input, and the value after validation.
Why are these types different? Because a schema can produce a value via casting that
would not pass validation!

```js
const schema = string().nullable().required();

schema.cast(null); // -> null
schema.validateSync(null); // ValidationError this is required!
```

By itself this seems weird, but has it uses when handling user input. To get a
TypeScript type that matches all possible `cast()` values, use `yup.TypeOf<typeof schema>`.
To produce a type that matches a valid object for the schema use `yup.Asserts<typeof schema>>`

```ts
import * as yup from 'yup';

const personSchema = yup.object({
firstName: yup
.string()
// Here we use `defined` instead of `required` to more closely align with
// TypeScript. Both will have the same effect on the resulting type by
// excluding `undefined`, but `required` will also disallow empty strings.
.defined(),
// defaults also affect the possible output type!
// schema with default values won't produce `undefined` values. Remember object schema
// have a default value built in.
nickName: yup.string().default('').nullable(),
gender: yup
.mixed()
// Note `as const`: this types the array as `["male", "female", "other"]`
// instead of `string[]`.
.oneOf(['male', 'female', 'other'] as const)
.defined(),
email: yup.string().nullable().notRequired().email(),
birthDate: yup.date().nullable().notRequired().min(new Date(1900, 0, 1)),
});
```

You can derive the TypeScript type as follows:

```ts
import type { Asserts, TypeOf } from 'yup';

const parsed: Typeof<typeof personSchema> = personSchema.cast(json);

const validated: Asserts<typeof personSchema> = personSchema.validateSync(
parsed,
);
```

You can also go the other direction, specifying an interface and ensuring that a schema would match it:

```ts
import { string, object, number, SchemaOf } from 'yup';

type Person = {
firstName: string;
};

// ✔️ compiles
const goodPersonSchema: SchemaOf<Person> = object({
firstName: string().defined(),
}).defined();

// ❌ errors:
// "Type 'number | undefined' is not assignable to type 'string'."
const badPersonSchema: SchemaOf<Person> = object({
firstName: number(),
});
```

### TypeScript settings

For type utilties to work correctly with required and nullable types you have
to set `strict: true` or `strictNullChecks: true` in your tsconfig.json.

### Extending built-in types

You can use TypeScript's interface merging behavior to extend the schema types
if needed. Type extensions should go in an "ambient" type def file such as your
`globals.d.ts`.

```ts
declare module 'yup' {
class StringSchema<TIn, TContext, TOut> {
myMethod(param: string): this;
}
}
```

> Watch out!: If your method needs to adjust schema generics, you likely
> need to also extend the Required*, and Defined* interfaces associated with
> each basic type. Consult the core types for examples on how to do this
4 changes: 2 additions & 2 deletions jest-sync.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"testEnvironment": "node",
"setupFilesAfterEnv": ["./test-setup.js"],
"roots": ["test"],
"testRegex": "\\.js",
"testPathIgnorePatterns": ["helpers\\.js"]
"testRegex": "\\.(t|j)s$",
"testPathIgnorePatterns": ["helpers\\.js", "\\.eslintrc\\.js", "types\\.ts"]
}
24 changes: 17 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
"precommit": "lint-staged",
"toc": "doctoc README.md --github",
"release": "rollout",
"build": "yarn build:commonjs && yarn build:modules && yarn toc",
"build:commonjs": "babel src --out-dir lib --delete-dir-on-start",
"build:modules": "babel src --out-dir es --delete-dir-on-start --env-name modules",
"build": "yarn 4c build && yarn toc",
"prepublishOnly": "yarn build"
},
"files": [
Expand Down Expand Up @@ -60,15 +58,22 @@
"roots": [
"test"
],
"testRegex": "\\.js",
"testRegex": "\\.(j|t)s$",
"testPathIgnorePatterns": [
"helpers\\.js"
"helpers\\.js",
"\\.eslintrc\\.js",
"types\\.ts"
]
},
"devDependencies": {
"@4c/rollout": "^2.1.10",
"@4c/cli": "^2.1.12",
"@4c/rollout": "^2.1.11",
"@4c/tsconfig": "^0.3.1",
"@babel/cli": "7.12.1",
"@babel/core": "7.12.3",
"@babel/plugin-proposal-logical-assignment-operators": "^7.12.1",
"@babel/preset-typescript": "^7.12.1",
"@typescript-eslint/parser": "^4.8.1",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.6.1",
Expand All @@ -86,6 +91,8 @@
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-ts-expect": "^1.0.1",
"eslint-plugin-typescript": "^0.14.0",
"husky": "^4.3.0",
"jest": "^26.6.1",
"lint-staged": "^10.4.2",
Expand All @@ -97,12 +104,15 @@
"rollup-plugin-size-snapshot": "^0.12.0",
"sinon": "^9.2.0",
"sinon-chai": "^3.5.0",
"synchronous-promise": "^2.0.15"
"synchronous-promise": "^2.0.15",
"typescript": "^4.0.5"
},
"dependencies": {
"@babel/runtime": "^7.10.5",
"@types/lodash": "^4.14.165",
"lodash": "^4.17.20",
"lodash-es": "^4.17.11",
"nanoclone": "^0.2.1",
"property-expr": "^2.0.4",
"toposort": "^2.0.2"
},
Expand Down
37 changes: 32 additions & 5 deletions src/Condition.js → src/Condition.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import has from 'lodash/has';
import isSchema from './util/isSchema';
import Reference from './Reference';
import { SchemaLike } from './types';

class Condition {
constructor(refs, options) {
export interface ConditionBuilder<T extends SchemaLike> {
(this: T, value: any, schema: T): SchemaLike;
(v1: any, v2: any, schema: T): SchemaLike;
(v1: any, v2: any, v3: any, schema: T): SchemaLike;
(v1: any, v2: any, v3: any, v4: any, schema: T): SchemaLike;
}

export type ConditionConfig<T extends SchemaLike> = {
is: any | ((...values: any[]) => boolean);
then?: SchemaLike | ((schema: T) => SchemaLike);
otherwise?: SchemaLike | ((schema: T) => SchemaLike);
};

export type ConditionOptions<T extends SchemaLike> =
| ConditionBuilder<T>
| ConditionConfig<T>;

export type ResolveOptions<TContext = any> = {
value?: any;
parent?: any;
context?: TContext;
};

class Condition<T extends SchemaLike = SchemaLike> {
fn: ConditionBuilder<T>;

constructor(public refs: Reference[], options: ConditionOptions<T>) {
this.refs = refs;

if (typeof options === 'function') {
Expand All @@ -23,9 +50,9 @@ class Condition {
let check =
typeof is === 'function'
? is
: (...values) => values.every((value) => value === is);
: (...values: any[]) => values.every((value) => value === is);

this.fn = function (...args) {
this.fn = function (...args: any[]) {
let options = args.pop();
let schema = args.pop();
let branch = check(...args) ? then : otherwise;
Expand All @@ -36,7 +63,7 @@ class Condition {
};
}

resolve(base, options) {
resolve(base: T, options: ResolveOptions) {
let values = this.refs.map((ref) =>
ref.getValue(options?.value, options?.parent, options?.context),
);
Expand Down
Loading