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

improve type inference for validate #5

Merged
merged 1 commit into from
Mar 3, 2024
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
59 changes: 26 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,39 +540,6 @@ isEmptyArray([1, 2, 3]); // false

Allows to validate runtime values (objects) with given schema or guard

### `validate` function args

One of the use cases for `validate` is to validate runtime values with given schema or guard

```ts
type FunctionExample = {
(value: string): string;
(value: string, otherValue: number): string;
(value: string, otherValue: number[]): string;
};

const example: FunctionExample = (...args: unknown[]) => {
if (validate(args, tuple(isString))) {
const [value] = args; // [string]
}
if (validate(args, tuple(isString, isNumber))) {
const [value, otherValue] = args; // [string, number]
}
if (validate(args, tuple(isString, isArrayOf(isNumber)))) {
const [value, otherValue] = args; // [string, number[]]
}

// fallback
};

/**
* This hack is required to correct type inference
* Although typescript v5+ has `const` genetic modifier, that allows to infer such cases correctly
* most of the projects use older versions of typescript, and this feature is breaking declaration files
*/
const tuple = <T extends unknown[]>(...args: T) => args;
```

### Usage

#### Validate object with schema
Expand Down Expand Up @@ -628,6 +595,32 @@ if (validate(arr, schema)) {
}
```

#### Validate function args

One of the useful use-cases is to validate overloaded function arguments

```ts
type FunctionExample = {
(value: string): void;
(value: string, otherValue: number): void;
(value: string, otherValue: number[]): void;
};

const example: FunctionExample = (...args: unknown[]) => {
if (validate(args, [isString])) {
const [value] = args; // [string]
}
if (validate(args, [isString, isNumber])) {
const [value, otherValue] = args; // [string, number]
}
if (validate(args, [isString, isArrayOf(isNumber)])) {
const [value, otherValue] = args; // [string, number[]]
}

// fallback
};
```

#### Validate value with guard

```tsx
Expand Down
3 changes: 2 additions & 1 deletion src/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type InferTypeSchema<TSchema> = TSchema extends unknown[]
? { [K in keyof TSchema]: InferTypeSchema<TSchema[K]> }
: InferGuardType<TSchema>;

export type TypeSchema = { [key: PropertyKey]: TypeSchema } | TypeSchema[] | Guard;
type ObjectSchema = { [K in PropertyKey]: TypeSchema };
export type TypeSchema = ObjectSchema | Guard | [] | [TypeSchema] | [TypeSchema, ...TypeSchema[]];

export type InferGuardType<TGuard> = TGuard extends Guard<infer TGuarded, any[]> ? TGuarded : never;
9 changes: 7 additions & 2 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import isHas from './guards/isHas';
import isArray from './guards/isArray';

type ValidateGuard = {
<TSchema extends TypeSchema>(value: unknown, schema: TSchema): value is InferTypeSchema<TSchema>;
<TSchema extends TypeSchema>(schema: TSchema): (value: unknown) => value is InferTypeSchema<TSchema>;
<TSchema extends TypeSchema>(
value: unknown,
schema: TSchema | Readonly<TSchema>,
): value is InferTypeSchema<TSchema>;
<TSchema extends TypeSchema>(schema: TSchema | Readonly<TSchema>): (
value: unknown,
) => value is InferTypeSchema<TSchema>;
};

const validateFactory = (options: { strict: boolean }) => {
Expand Down
132 changes: 99 additions & 33 deletions tests/validate.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,110 @@
import { test, expect } from 'vitest';
import { test, expect, describe } from 'vitest';
import is, { validate, validateStrict } from '../src';
import { assertGuardedType } from './utils';

test('Should validate by schema shape (validate)', () => {
const obj = {
a: 1,
b: 'string',
c: {
d: true,
e: {
f: 1,
g: 'string',
describe('Validate runtime tests', () => {
test('Should validate by schema shape (validate)', () => {
const obj = {
a: 1,
b: 'string',
c: {
d: true,
e: {
f: 1,
g: 'string',
},
},
},
};

const schema = {
a: is.Number,
b: is.$some(is.String, is.Nil),
c: {
d: is.Boolean,
e: {
f: is.Number,
g: is.String,
};

const schema = {
a: is.Number,
b: is.$some(is.String, is.Nil),
c: {
d: is.Boolean,
e: {
f: is.Number,
g: is.String,
},
},
},
};
};

expect(validate(obj, schema)).toBe(true);

expect(validate(42, is.Number)).toBe(true);
expect(validate(42, is.$some(is.Number, is.Nil))).toBe(true);

expect(validate(42, is.String)).toBe(false);
expect(validate({ a: 1 }, schema)).toBe(false);
expect(validate('SOME STRING VALUE', schema)).toBe(false);

expect(validate({ a: 1, otherParam: 'here' }, { a: is.Number })).toBe(true);
expect(validateStrict({ a: 1, otherParam: 'here' }, { a: is.Number })).toBe(false);

expect(validate([21, 2, 32], is.ArrayOf(is.Number))).toBe(true);
expect(validate(is.ArrayOf(is.Number))([21, 2, 32])).toBe(true);

expect(() => validate(42, 'UNKNOWN_SCHEMA' as any)).toThrowError();
});

expect(validate(obj, schema)).toBe(true);
test('Should validate array schema', () => {
const schema = [is.Number, is.String, is.Boolean];

expect(validate([1, '2', true], schema)).toBe(true);
expect(validate([1, '2', true, 4], schema)).toBe(false);
expect(validate([1, '2'], schema)).toBe(false);
expect(validate([1, '2', 'true'], schema)).toBe(false);
});

test('Should validate array schema (complex type)', () => {
const schema = [is.Number, is.String, is.Boolean, [validate({ a: is.Number, b: is.String })]];

expect(validate([1, '2', true, [{ a: 1, b: '2' }]], schema)).toBe(true);
expect(validate([1, '2', true, [{ a: 1, b: 2 }]], schema)).toBe(false);
expect(validate([1, '2', true, [{ a: 1 }]], schema)).toBe(false);
expect(validate([1, '2', true, [{ a: 1, b: '2', c: 3 }]], schema)).toBe(true); // extra properties are allowed
});
});

describe('Validate static typing tests', () => {
test('should infer guarded type from object schema', () => {
const schema = {
a: is.Number,
b: is.$some(is.String, is.Nil),
c: {
d: is.Boolean,
e: {
f: is.Number,
g: is.String,
},
},
};

expect(validate(42, is.Number)).toBe(true);
expect(validate(42, is.$some(is.Number, is.Nil))).toBe(true);
type ExpectedType = {
a: number;
b: string | null | undefined;
c: {
d: boolean;
e: {
f: number;
g: string;
};
};
};

expect(validate(42, is.String)).toBe(false);
expect(validate({ a: 1 }, schema)).toBe(false);
assertGuardedType<ExpectedType>()(validate(schema));
assertGuardedType<ExpectedType>()(validateStrict(schema));
});

expect(validate({ a: 1, otherParam: 'here' }, { a: is.Number })).toBe(true);
expect(validateStrict({ a: 1, otherParam: 'here' }, { a: is.Number })).toBe(false);
test('should infer guarded type from array schema', () => {
type ExpectedType = [number, string, boolean];

expect(validate([21, 2, 32], is.ArrayOf(is.Number))).toBe(true);
expect(validate(is.ArrayOf(is.Number))([21, 2, 32])).toBe(true);
assertGuardedType<ExpectedType>()(validate([is.Number, is.String, is.Boolean]), []);
assertGuardedType<ExpectedType>()(validateStrict([is.Number, is.String, is.Boolean]), []);
});

expect(() => validate(42, 'UNKNOWN_SCHEMA' as any)).toThrowError();
test('should infer guarded type from guard schema', () => {
assertGuardedType<number>()(validate(is.Number));
assertGuardedType<string>()(validate(is.String));
assertGuardedType<string | null | undefined>()(validate(is.$some(is.String, is.Nil)));
});
});
Loading