From 9394d5453e5d3c86f58c76de4ca58f99ef19489e Mon Sep 17 00:00:00 2001 From: George Zhao Date: Tue, 24 May 2022 23:02:32 +1000 Subject: [PATCH] Add `Exact` type (#259) Co-authored-by: Sindre Sorhus --- index.d.ts | 1 + readme.md | 1 + source/exact.d.ts | 51 +++++++++++++++ source/internal.d.ts | 9 +++ test-d/exact.ts | 145 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 source/exact.d.ts create mode 100644 test-d/exact.ts diff --git a/index.d.ts b/index.d.ts index 27f1b8294..37a841f30 100644 --- a/index.d.ts +++ b/index.d.ts @@ -55,6 +55,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'; diff --git a/readme.md b/readme.md index 1d7e95e9c..5636533c2 100644 --- a/readme.md +++ b/readme.md @@ -190,6 +190,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 that does not allow extra properties. ### JSON diff --git a/source/exact.d.ts b/source/exact.d.ts new file mode 100644 index 000000000..e1806fde9 --- /dev/null +++ b/source/exact.d.ts @@ -0,0 +1,51 @@ +import type {Primitive} from './primitive'; +import type {KeysOfUnion} from './internal'; + +/** +Create a type that does not allow extra properties, meaning it only allows properties that are explicitly declared. + +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. + +*Please upvote [this issue](https://github.com/microsoft/TypeScript/issues/12936) if you want to have this type as a built-in in TypeScript.* + +@example +``` +type OnlyAcceptName = {name: string}; + +function onlyAcceptName(args: OnlyAcceptName) {} + +// TypeScript complains about excess properties when an object literal is provided. +onlyAcceptName({name: 'name', id: 1}); +//=> `id` is excess + +// TypeScript does not complain about excess properties when the provided value is a variable (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>(args: T) {} + +const invalidInput = {name: 'name', id: 1}; +onlyAcceptNameImproved(invalidInput); // Compilation error +``` + +[Read more](https://stackoverflow.com/questions/49580725/is-it-possible-to-restrict-typescript-object-to-contain-only-properties-defined) + +@category Utilities +*/ +export type Exact = ParameterType extends Primitive + ? ParameterType + /* + Create a type from `ParameterType` and `InputType` and change keys exclusive to `InputType` to `never`. + - Generate a list of keys that exists in `InputType` but not in `ParameterType`. + - Mark these excess keys as `never`. + */ + : {[Key in keyof ParameterType]: Exact} & Record>, never>; diff --git a/source/internal.d.ts b/source/internal.d.ts index 1ffee04b5..257f53caf 100644 --- a/source/internal.d.ts +++ b/source/internal.d.ts @@ -42,3 +42,12 @@ export type Subtract = BuildTuple extends Matches any primitive, `Date`, or `RegExp` value. */ export type BuiltIns = Primitive | Date | RegExp; + +/** +Gets keys from a type. Similar to `keyof` but this one also works for union types. + +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 extends T ? keyof T : never; diff --git a/test-d/exact.ts b/test-d/exact.ts new file mode 100644 index 000000000..101e0adf4 --- /dev/null +++ b/test-d/exact.ts @@ -0,0 +1,145 @@ +import type {Exact} from '../index'; + +{ // Spec - string type + type Type = string; + const fn = >(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 = >(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 = >(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 = >(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 = >(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 = >(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); + } +}