Skip to content

Commit

Permalink
feat: typescript support (#1134)
Browse files Browse the repository at this point in the history
Rewritten in TS, and with some (hopefully) improvements in accuracy over the DT types. Along with the rewrite come a few organizational and behavioral breaking changes. See the typescript docs for more info about how the types here differ from DT.

BREAKING CHANGE: `concat` doesn't check for "unset" nullable or presence when merging meaning the nullability and presence will always be the same as the schema passed to `concat()`. They can be overridden if needed after concatenation

BREAKING CHANGE: schema factory functions are no longer constructors. The classes are now also exported for extension or whatever else. e.g. `import { StringSchema, string } from 'yup'`
  • Loading branch information
jquense authored Dec 3, 2020
1 parent b6262ea commit b97c39d
Show file tree
Hide file tree
Showing 64 changed files with 6,553 additions and 2,532 deletions.
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

0 comments on commit b97c39d

Please sign in to comment.