dubzzz / fast-check

Property based testing framework for JavaScript (like QuickCheck) written in TypeScript
https://fast-check.dev/
MIT License
4.28k stars 178 forks source link

Better typings for record in v4 #4570

Open dubzzz opened 8 months ago

dubzzz commented 8 months ago

💡 Idea

The aim would be to simplify the typings (from a user point-of-view) of record. We want he final users to be able to write record<ExpectedTypeWithNullable>.

See playground below for the requirements: https://github.com/dubzzz/fast-check/issues/4570#issuecomment-1878264000

dubzzz commented 8 months ago

First drafts:

type Pretty<T> = { [K in keyof T]: T[K] } & {}

type IsOptional<TType, TKey extends keyof TType> = Pick<TType, TKey> extends Record<TKey, TType[TKey]>  ? false : true;
const is1: Pretty<IsOptional<{s?: string}, 's'>> = true;
const is2: Pretty<IsOptional<{s?: string|undefined}, 's'>> = true;
const isNot1: Pretty<IsOptional<{s: string|undefined}, 's'>> = false;
const isNot2: Pretty<IsOptional<{s: string}, 's'>> = false;
const isNot3: Pretty<IsOptional<{s: undefined}, 's'>> = false;

type OptionalKeysOf<TType> = keyof { [K in keyof TType as IsOptional<TType, K> extends true ? K : never]: never }
type RequiredKeysOf<TType> = keyof { [K in keyof TType as IsOptional<TType, K> extends true ? never : K]: never }
type PPP = RequiredKeysOf<{s?: string, n: number | undefined }>

type RecordConstraints<TOut> = {requiredKeys: readonly RequiredKeysOf<TOut>[]}

// "All keys required" matches "Some keys potentially optional"
declare function record<TOut>(
  shape: 
    { [K in keyof TOut as IsOptional<TOut, K> extends true ? never : K]: Arbitrary<TOut[K]> }
    & { [K in keyof TOut as IsOptional<TOut, K> extends true ? K : never]?: Arbitrary<TOut[K]> | Arbitrary<Exclude<TOut[K], undefined>> },
): TOut;

declare function record<TOut, TConstraints extends RecordConstraints<TOut>>(
  shape: 
    { [K in keyof TOut as IsOptional<TOut, K> extends true ? never : K]: Arbitrary<TOut[K]> }
    & { [K in keyof TOut as IsOptional<TOut, K> extends true ? K : never]?: Arbitrary<TOut[K]> | Arbitrary<Exclude<TOut[K], undefined>> },
  options: TConstraints
): Pretty<TConstraints>;

const a1 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() }
)
const a2 = record<{s?: string, n?: number }>(
  { s: arbString(), n: arbNat() },
)
const a22 = record<{s?: string, n?: number }>( // we have to make sure arbNatOrUndefined would not pass as n? means number when set, not number|undefined
  { s: arbString(), n: arbNatOrUndefined() },
)
const a3 = record<{s?: string, n?: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: [] }
)
const a4 = record<{s?: string, n?: number }>(
  {  }
)
const a5 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: ['s', 'n'] as const }
)

// Should not compile
const b1 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: [] }
)
const b2 = record<{s?: string, n: number | undefined }>(
  {  }
)
dubzzz commented 8 months ago

A step forward:

type Arbitrary<T> = {
  generate: () => T;
  shrink: (value: T) => IterableIterator<T>;
}

declare function arbNat(): Arbitrary<number>;
declare function arbNatOrUndefined(): Arbitrary<number|undefined>;
declare function arbString(): Arbitrary<string>;
declare function arbConstant<T>(v: T): Arbitrary<T>;

type Pretty<T> = { [K in keyof T]: T[K] } & {}

type IsOptional<TType, TKey extends keyof TType> = Pick<TType, TKey> extends Record<TKey, TType[TKey]> ? false : true;
const is1: Pretty<IsOptional<{s?: string}, 's'>> = true;
const is2: Pretty<IsOptional<{s?: string|undefined}, 's'>> = true;
const isNot1: Pretty<IsOptional<{s: string|undefined}, 's'>> = false;
const isNot2: Pretty<IsOptional<{s: string}, 's'>> = false;
const isNot3: Pretty<IsOptional<{s: undefined}, 's'>> = false;

type OptionalKeysOf<TType> = keyof { [K in keyof TType as IsOptional<TType, K> extends true ? K : never]: never }
type RequiredKeysOf<TType> = keyof { [K in keyof TType as IsOptional<TType, K> extends true ? never : K]: never }
type PPP = RequiredKeysOf<{s?: string, n: number | undefined }>

// From https://github.com/Microsoft/TypeScript/issues/13298
type UnionToIntersection<U> = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never;
type UnionToTuple<T, A extends any[] = []> = UnionToIntersection<T extends never ? never : (t: T) => T> extends (_: never) => infer W ? UnionToTuple<Exclude<T, W>, [...A, W]> : A;
type QQQ = UnionToTuple<"a" | "b" | "c">

type RecordShape<TOut> = 
    { [K in keyof TOut as IsOptional<TOut, K> extends true ? never : K]: Arbitrary<TOut[K]> }
    & { [K in keyof TOut as IsOptional<TOut, K> extends true ? K : never]?: Arbitrary<TOut[K]> | Arbitrary<Exclude<TOut[K], undefined>> };
type RecordConstraints<TOut> = {requiredKeys: UnionToTuple<RequiredKeysOf<TOut>>}

declare function record<TOut>(shape: RecordShape<TOut>): TOut;
declare function record<TOut>(
  shape: RecordShape<TOut>,
  options: RecordConstraints<TOut>
): TOut;

const a1 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() }
)
const a2 = record<{s?: string, n?: number }>(
  { s: arbString(), n: arbNat() },
)
const a22 = record<{s?: string, n?: number }>( // we have to make sure arbNatOrUndefined would not pass as n? means number when set, not number|undefined
  { s: arbString(), n: arbNatOrUndefined() },
)
const a3 = record<{s?: string, n?: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: [] }
)
const a4 = record<{s?: string, n?: number }>(
  {  }
)
const a5 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: ['n', 's'] }
)
const a6 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: ['s', 'n'] }
)

// Should not compile
const b1 = record<{s: string, n: number }>(
  { s: arbString(), n: arbNat() },
  { requiredKeys: [] }
)
const b2 = record<{s?: string, n: number | undefined }>(
  {  }
)
dubzzz commented 8 months ago

Here is a Playground to play with to adapt current typings for record: link.

Code of the playground ```ts // Playground to build record // record in v3+ (v3 with options dropped to be closer to v4) type RecordValue = TConstraints extends { requiredKeys: (infer TKeys)[] } ? Partial & Pick : T; type RecordConstraints = { requiredKeys?: T[]; noNullPrototype?: boolean; }; declare function record(recordModel: { [K in keyof T]: Arbitrary }): Arbitrary>; declare function record>(recordModel: { [K in keyof T]: Arbitrary }, constraints: TConstraints): Arbitrary>; // Fake definitions for fast-check class Arbitrary { generate(): T { throw new Error("Not implemented!"); } shrink(value: T): IterableIterator { throw new Error("Not implemented!"); } } declare function arbNat(): Arbitrary; declare function arbOption(arb: Arbitrary, constraints?: {nil?: TNil}): Arbitrary; declare function arbString(): Arbitrary; declare function arbConstant(v: T): Arbitrary; const fc = { Arbitrary, nat: arbNat, constant: arbConstant, string: arbString, option: arbOption, record }; // New set of tests that we want to make pass for v4 expectType>()( record<{s: string}>({ s: fc.string() }), "" ); expectType>()( record<{s?: string}>({ s: fc.string() }, { requiredKeys: ["s"] }), "" ); expectType>()( record<{s?: string}>({ s: fc.string() }, { requiredKeys: [] }), "" ); expectType>()( record<{s?: string}>({ s: fc.option(fc.string(), {nil:undefined}) }, { requiredKeys: [] }), "" ); expectType>()( record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['b', 'c'] }), "" ); expectType>()( record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['c', 'b'] }), "same as above but with b and c reversed" ); // @ts-expect-error - should not pass when keys are missing (missing b) record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['c'] }); // @ts-expect-error - should not pass when keys linked to otpional keys are added record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['a', 'b', 'c'] }); // @ts-expect-error - should not pass when optional and no requiredKeys specified record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }); // Tests running on the current version declare const mySymbol1: unique symbol; declare const mySymbol2: unique symbol; expectType>()( fc.record({ a: fc.nat(), b: fc.string() }), '"record" can contain multiple types', ); expectType>()( fc.record({ [mySymbol1]: fc.nat(), [mySymbol2]: fc.string() }), '"record" can be indexed using unique symbols as keys', ); expectType>()( fc.record({ a: fc.nat(), b: fc.string() }, {}), '"record" accepts empty constraints', ); expectType>()( fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: [] }), '"record" only applies optional on keys declared within requiredKeys even when empty', ); expectType>()( fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: ['a'] }), '"record" only applies optional on keys declared within requiredKeys even if unique', ); expectType>()( fc.record({ a: fc.nat(), b: fc.string(), c: fc.string() }, { requiredKeys: ['a', 'c'] }), '"record" only applies optional on keys declared within requiredKeys even if multiple ones specified', ); expectType>()( fc.record({ a: fc.nat(), b: fc.option(fc.string(), { nil: undefined }) }, { requiredKeys: [] }), '"record" only applies optional on keys declared within requiredKeys and preserves existing |undefined when adding ?', ); expectType>()( fc.record({ [mySymbol1]: fc.nat(), [mySymbol2]: fc.string() }, { requiredKeys: [mySymbol1] as [typeof mySymbol1] }), '"record" only applies optional on keys declared within requiredKeys even if it contains symbols', ); expectType>()( fc.record( { [mySymbol1]: fc.nat(), [mySymbol2]: fc.string(), a: fc.nat(), b: fc.string() }, { requiredKeys: [mySymbol1, 'a'] as [typeof mySymbol1, 'a'] }, ), '"record" only applies optional on keys declared within requiredKeys even if it contains symbols and normal keys', ); type Query = { data: { field: 'X' } }; expectType>()( // issue 1453 fc.record({ data: fc.record({ field: fc.constant('X') }) }), '"record" can be passed the requested type in <*>', ); expectType>>()( // issue 1453 fc.record>({ data: fc.record({ field: fc.constant('X') }) }), '"record" can be passed something assignable to the requested type in <*>', ); // @ts-expect-error - requiredKeys references an unknown key fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: ['c'] }); // @ts-expect-error - record expects arbitraries not raw values fc.record({ a: 1 }); // MUST ERROR with exactOptionalPropertyTypes expectType>()(fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: [] }), '"record" only applies optional on keys declared within requiredKeys by adding ? without |undefined'); // Assertions on types copied from @fast-check/expect-type declare type Not = T extends true ? false : true; declare type And = T extends true ? (U extends true ? true : false) : false; declare type Or = T extends false ? (U extends false ? false : true) : true; declare type IsNever = [T] extends [never] ? true : false; declare type Extends = T extends U ? true : false; declare type ExtendsString = Extends extends boolean ? boolean extends Extends ? true : false : false; // Extends is: false for unknown but boolean for any declare type IsUnknown = And>, Extends>, And, Not>>>; declare type IsAny = And>, Not>>, And, Extends extends true ? true : false>>; declare type DeeperIsSame = IsAny extends false ? T extends object ? { [K in keyof (T | U)]: IsSame } extends { [K in keyof (T | U)]: true } ? true : false : true : false; declare type IsSame = [T, U] extends [U, T] ? Or, IsAny>, And, IsUnknown>>, And, IsNever>>, And>, Not>>, And>, Not>>>,And>, Not>>>, DeeperIsSame>> : false; declare function expectType(): (arg: TReal,...noArgs: IsSame extends true ? [string] : [{ expected: TExpectedType; got: TReal }]) => void; declare function expectTypeAssignable(): (arg: TReal,...noArgs: Extends extends true ? [string] : [{ expected: TExpectedType; got: TReal }]) => void; ```
ssalbdivad commented 8 months ago

This looks good! Feel free to DM me on Discord/Twitter and we can schedule a chat next week and go through the Playground link.

I might have some ideas for handling tuple inputs that could be useful as well as testing + type errors and would be happy to discuss if it would be helpful to an awesome library like this!

dubzzz commented 1 month ago

I played again on this one, but so far I have not succeeded into finding something that could work when the users explicitly specify something between the <?> and when they don't. The following version seems to work quite well (just for a few keys being required) with <?> but sucks for the other:

// Playground to build record

// record in v3+ (v3 with options dropped to be closer to v4)
type RecordConstraints<T extends string | number | symbol> = { requiredKeys?: ArrayWithAllKeys<T>; noNullPrototype?: boolean; };

type NonOptionalKeyOf<T> = keyof { [K in keyof T as undefined extends T[K] ? never : K]: K }
type Flatten<T> = T extends [infer THead, ...infer TRest]
  ? THead extends [infer TNestedHead, ...infer TNestedRest]
    ? Flatten<[TNestedHead, ...TNestedRest, ...TRest]>
    : THead extends []
      ? Flatten<[...TRest]>
      : [THead, ...Flatten<TRest>]
  : T;
type ArrayWithAllKeys<TKeys extends string | number | symbol> = [TKeys] extends [never] ? [] : { [K in TKeys]: Flatten<[K, ArrayWithAllKeys<Exclude<TKeys, K>>]> }[TKeys]
type PP = ArrayWithAllKeys<"a" | "b">
declare function record<T>(recordModel: { [K in keyof T]: Arbitrary<T[K]> }): Arbitrary<T>;
declare function record<T>(recordModel: { [K in keyof T]: Arbitrary<T[K]> }, constraints: RecordConstraints<NonOptionalKeyOf<T>>): Arbitrary<T>;

// Fake definitions for fast-check
class Arbitrary<T> {
  generate(): T { throw new Error("Not implemented!"); }
  shrink(value: T): IterableIterator<T> { throw new Error("Not implemented!"); }
}
declare function arbNat(): Arbitrary<number>;
declare function arbOption<T, TNil = null>(arb: Arbitrary<T>, constraints?: {nil?: TNil}): Arbitrary<T | TNil>;
declare function arbString(): Arbitrary<string>;
declare function arbConstant<T>(v: T): Arbitrary<T>;
const fc = { Arbitrary, nat: arbNat, constant: arbConstant, string: arbString, option: arbOption, record };

// New set of tests that we want to make pass for v4
expectType<Arbitrary<{ s: string }>>()(
  record<{s: string}>({ s: fc.string() }),
  ""
);
expectType<Arbitrary<{ s: string }>>()(
  record<{s: string}>({ s: fc.string() }, {}),
  ""
);
expectType<Arbitrary<{ s: string }>>()(
  record<{s: string}>({ s: fc.string() }, { requiredKeys: ['s'] }),
  ""
);
expectType<Arbitrary<{ s: string }>>()(
  record({ s: fc.string() }, {}),
  ""
);
expectType<Arbitrary<{ s: string }>>()(
  record({ s: fc.string() }, { requiredKeys: ['s'] }),
  ""
);
expectType<Arbitrary<{ s?: string }>>()(
  record({ s: fc.string() }, { requiredKeys: [] }),
  ""
);
expectType<Arbitrary<{ s?: string }>>()(
  record<{s?: string}>({ s: fc.string() }, { requiredKeys: [] }),
  ""
);
expectType<Arbitrary<{ s?: string | undefined }>>()(
  record<{s?: string}>({ s: fc.option(fc.string(), {nil:undefined}) }, { requiredKeys: [] }),
  ""
);
expectType<Arbitrary<{ a: string, b?: number, c: number | undefined }>>()(
  record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['a', 'c'] }),
  ""
);
expectType<Arbitrary<{ a: string, b?: number, c: number | undefined }>>()(
  record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['c', 'a'] }),
  "same as above but with b and c reversed"
);
// @ts-expect-error - should not pass when keys are missing (missing b)
record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['c'] });
// @ts-expect-error - should not pass when keys linked to otpional keys are added
record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) }, { requiredKeys: ['a', 'b', 'c'] });
// @ts-expect-error - should not pass when optional and no requiredKeys specified
record<{ a: string, b?: number, c: number | undefined }>({ a: fc.string(), b: fc.nat(), c: fc.option(fc.nat(), {nil:undefined}) });

// Tests  running on the current version
declare const mySymbol1: unique symbol;
declare const mySymbol2: unique symbol;
expectType<Arbitrary<{ a: number; b: string }>>()(
  fc.record({ a: fc.nat(), b: fc.string() }),
  '"record" can contain multiple types',
);
expectType<Arbitrary<{ [mySymbol1]: number; [mySymbol2]: string }>>()(
  fc.record({ [mySymbol1]: fc.nat(), [mySymbol2]: fc.string() }),
  '"record" can be indexed using unique symbols as keys',
);
expectType<Arbitrary<{ a: number; b: string }>>()(
  fc.record({ a: fc.nat(), b: fc.string() }, {}),
  '"record" accepts empty constraints',
);
expectType<Arbitrary<{ a?: number; b?: string }>>()(
  fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: [] }),
  '"record" only applies optional on keys declared within requiredKeys even when empty',
);
expectType<Arbitrary<{ a: number; b?: string }>>()(
  fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: ['a'] }),
  '"record" only applies optional on keys declared within requiredKeys even if unique',
);
expectType<Arbitrary<{ a: number; b?: string; c: string }>>()(
  fc.record({ a: fc.nat(), b: fc.string(), c: fc.string() }, { requiredKeys: ['a', 'c'] }),
  '"record" only applies optional on keys declared within requiredKeys even if multiple ones specified',
);
expectType<Arbitrary<{ a?: number; b?: string | undefined }>>()(
  fc.record({ a: fc.nat(), b: fc.option(fc.string(), { nil: undefined }) }, { requiredKeys: [] }),
  '"record" only applies optional on keys declared within requiredKeys and preserves existing |undefined when adding ?',
);
expectType<Arbitrary<{ [mySymbol1]: number; [mySymbol2]?: string }>>()(
  fc.record({ [mySymbol1]: fc.nat(), [mySymbol2]: fc.string() }, { requiredKeys: [mySymbol1] as [typeof mySymbol1] }),
  '"record" only applies optional on keys declared within requiredKeys even if it contains symbols',
);
expectType<Arbitrary<{ [mySymbol1]: number; [mySymbol2]?: string; a: number; b?: string }>>()(
  fc.record(
    { [mySymbol1]: fc.nat(), [mySymbol2]: fc.string(), a: fc.nat(), b: fc.string() },
    { requiredKeys: [mySymbol1, 'a'] as [typeof mySymbol1, 'a'] },
  ),
  '"record" only applies optional on keys declared within requiredKeys even if it contains symbols and normal keys',
);
type Query = { data: { field: 'X' } };
expectType<Arbitrary<Query>>()(
  // issue 1453
  fc.record<Query>({ data: fc.record({ field: fc.constant('X') }) }),
  '"record" can be passed the requested type in <*>',
);
expectType<Arbitrary<Partial<Query>>>()(
  // issue 1453
  fc.record<Partial<Query>>({ data: fc.record({ field: fc.constant('X') }) }),
  '"record" can be passed something assignable to the requested type in <*>',
);
// @ts-expect-error - requiredKeys references an unknown key
fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: ['c'] });
// @ts-expect-error - record expects arbitraries not raw values
fc.record({ a: 1 });
// MUST ERROR with exactOptionalPropertyTypes
expectType<Arbitrary<{ a?: number; b?: string | undefined }>>()(fc.record({ a: fc.nat(), b: fc.string() }, { requiredKeys: [] }), '"record" only applies optional on keys declared within requiredKeys by adding ? without |undefined');

// Assertions on types copied from @fast-check/expect-type
declare type Not<T> = T extends true ? false : true;
declare type And<T, U> = T extends true ? (U extends true ? true : false) : false;
declare type Or<T, U> = T extends false ? (U extends false ? false : true) : true;
declare type IsNever<T> = [T] extends [never] ? true : false;
declare type Extends<T, U> = T extends U ? true : false;
declare type ExtendsString<T> = Extends<T, string> extends boolean ? boolean extends Extends<T, string> ? true : false : false; // Extends<T, string> is: false for unknown but boolean for any
declare type IsUnknown<T> = And<And<Not<IsNever<T>>, Extends<T, unknown>>, And<Extends<unknown, T>, Not<ExtendsString<T>>>>;
declare type IsAny<T> = And<And<Not<IsNever<T>>, Not<IsUnknown<T>>>, And<Extends<T, any>, Extends<any, T> extends true ? true : false>>;
declare type DeeperIsSame<T, U> = IsAny<T> extends false ? T extends object ? { [K in keyof (T | U)]: IsSame<T[K], U[K]> } extends { [K in keyof (T | U)]: true } ? true : false : true : false;
declare type IsSame<T, U> = [T, U] extends [U, T] ? Or<Or<Or<And<IsAny<T>, IsAny<U>>, And<IsUnknown<T>, IsUnknown<U>>>, And<IsNever<T>, IsNever<U>>>, And<And<And<And<Not<IsAny<T>>, Not<IsAny<U>>>, And<Not<IsUnknown<T>>, Not<IsUnknown<U>>>>,And<Not<IsNever<T>>, Not<IsNever<U>>>>, DeeperIsSame<T, U>>> : false;
declare function expectType<TExpectedType>(): <TReal>(arg: TReal,...noArgs: IsSame<TExpectedType, TReal> extends true ? [string] : [{ expected: TExpectedType; got: TReal }]) => void;
declare function expectTypeAssignable<TExpectedType>(): <TReal>(arg: TReal,...noArgs: Extends<TReal, TExpectedType> extends true ? [string] : [{ expected: TExpectedType; got: TReal }]) => void;

I tried playing with NoInfer but I failed. I believe the thing building the tuples of all required keys sucks.