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

Add Exact type #259

Merged
merged 11 commits into from May 24, 2022
Merged
1 change: 1 addition & 0 deletions index.d.ts
Expand Up @@ -54,6 +54,7 @@ export {
NonNegativeInteger,
} from './source/numeric';
export {StringKeyOf} from './source/string-key-of';
export {Exact} from './source/exact';

// Template literal types
export {CamelCase} from './source/camel-case';
Expand Down
1 change: 1 addition & 0 deletions readme.md
Expand Up @@ -122,6 +122,7 @@ Click the type names for complete docs.
- [`Get`](source/get.d.ts) - Get a deeply-nested property from an object using a key path, like [Lodash's `.get()`](https://lodash.com/docs/latest#get) function.
- [`StringKeyOf`](source/string-key-of.d.ts) - Get keys of the given type as strings.
- [`Schema`](source/schema.d.ts) - Create a deep version of another object type where property values are recursively replaced into a given value type.
- [`Exact`](source/exact.d.ts) - Create a type from type A and B and changes keys exclusive to type B to `never`.

### JSON

Expand Down
52 changes: 52 additions & 0 deletions source/exact.d.ts
@@ -0,0 +1,52 @@
import {Primitive} from './primitive';
import {KeysOfUnion} from './internal';

/**
Create a type from type A and B and changes keys exclusive to type B to `never`.

This is useful for function type-guarding to reject arguments with excess
properties. Due to the nature of TypeScript, it does not complain if excess properties are
provided unless the provided value is an object literal.

@example
```
type OnlyAcceptName = {name: string};

function onlyAcceptName(args: OnlyAcceptName) {}

// TypeScript complains this because it's an object literal.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo

Copy link
Contributor Author

@zorji zorji Mar 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure which word has typo but I rewrite this sentence and hopefully this makes more sense.

I have updated the other comments as suggested.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend using a grammar checker like Grammarly. It would have caught this.

onlyAcceptName({name: 'name', id: 1});
//=> `id` is excess

// TypeScript does not complain because it's not an object literal.
const invalidInput = {name: 'name', id: 1};
onlyAcceptName(invalidInput); // No errors
```

Having `Exact` allows TypeScript to reject excess properties.

@example
```
import {Exact} from 'type-fest';

type OnlyAcceptName = {name: string};

function onlyAcceptNameImproved<T extends Exact<OnlyAcceptName, T>>(args: T) {}

const invalidInput = {name: 'name', id: 1};

onlyAcceptNameImproved(invalidInput); // Compilation error
```

The solution of `Exact` is
- take both the preferred type and actual provided type as input.
- generates the list of keys that exist in the provided type but not in the
defined type.
- mark these excess keys as `never`
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an internal code comment, not in the doc comment.

```

@category Utilities
*/
export type Exact<ParameterType, InputType extends ParameterType> = ParameterType extends Primitive
? ParameterType
: {[Key in keyof ParameterType]: Exact<ParameterType[Key], InputType[Key]>} & Record<Exclude<keyof InputType, KeysOfUnion<ParameterType>>, never>;
9 changes: 9 additions & 0 deletions source/internal.d.ts
Expand Up @@ -42,3 +42,12 @@ export type Subtract<A extends number, B extends number> = BuildTuple<A> extends
Matches any primitive, `Date`, or `RegExp` value.
*/
export type BuiltIns = Primitive | Date | RegExp;

/**
Returns the accessible keys that also works for union type.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea what this is trying to say. But from reading the SO answer, I guess you meant something like this:

Get all the keys from types in a union type.


The reason a simple `keyof Union` does not work is because `keyof` always returns the accessible keys of a type. In the case of a union that will only be the common keys.

@link https://stackoverflow.com/a/49402091
*/
export type KeysOfUnion<T> = T extends T ? keyof T : never;
145 changes: 145 additions & 0 deletions test-d/exact.ts
@@ -0,0 +1,145 @@
import {Exact} from '../index';

{ // Spec - string type
type Type = string;
const fn = <T extends Exact<Type, T>>(args: T) => args;

{ // It should accept string
const input = '';
fn(input);
}

{ // It should reject number
const input = 1;
// @ts-expect-error
fn(input);
}

{ // It should reject object
const input = {};
// @ts-expect-error
fn(input);
}
}

{ // Spec - array
type Type = Array<{code: string; name?: string}>;
const fn = <T extends Exact<Type, T>>(args: T) => args;

{ // It should accept array with required property only
const input = [{code: ''}];
fn(input);
}

{ // It should accept array with optional property
const input = [{code: '', name: ''}];
fn(input);
}

{ // It should reject array with excess property
const input = [{code: '', name: '', excessProperty: ''}];
// @ts-expect-error
fn(input);
}

{ // It should reject invalid type
const input = '';
// @ts-expect-error
fn(input);
}
}

{ // Spec - object
type Type = {code: string; name?: string};
const fn = <T extends Exact<Type, T>>(args: T) => args;

{ // It should accept object with required property only
const input = {code: ''};
fn(input);
}

{ // It should accept object with optional property
const input = {code: '', name: ''};
fn(input);
}

{ // It should reject object with excess property
const input = {code: '', name: '', excessProperty: ''};
// @ts-expect-error
fn(input);
}

{ // It should reject invalid type
const input = '';
// @ts-expect-error
fn(input);
}
}

{ // Spec - union - only object
type Type = {code: string} | {name: string};
const fn = <T extends Exact<Type, T>>(args: T) => args;

{ // It should accept type a
const input = {code: ''};
fn(input);
}

{ // It should accept type b
const input = {name: ''};
fn(input);
}

{ // It should reject intersection
const input = {name: '', code: ''};
// @ts-expect-error
fn(input);
}
}

{ // Spec - union - mixture object/primitive
type Type = {code: string} | string;
const fn = <T extends Exact<Type, T>>(args: T) => args;

{ // It should accept type a
const input = {code: ''};
fn(input);
}

{ // It should accept type b
const input = '';
fn(input);
}

{ // It should reject intersection
const input = {name: '', code: ''};
// @ts-expect-error
fn(input);
}
}

{ // Spec - jsonschema2ts generated request type with additionalProperties: true
type Type = {
body: {
[k: string]: unknown;
code: string;
name?: string;
};
};
const fn = <T extends Exact<Type, T>>(args: T) => args;

{ // It should accept input with required property only
const input = {body: {code: ''}};
fn(input);
}

{ // It should accept input with optional property
const input = {body: {code: '', name: ''}};
fn(input);
}

{ // It should allow input with excess property
const input = {body: {code: '', name: '', excessProperty: ''}};
fn(input);
}
}