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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better typings for record in v4 #4570

Open
dubzzz opened this issue Dec 26, 2023 · 4 comments
Open

Better typings for record in v4 #4570

dubzzz opened this issue Dec 26, 2023 · 4 comments
Labels
good first issue easy issue to start with - contributions welcome help wanted contributions welcome 鉁旓笍 Idea to investigate 馃挕 Idea 馃挜 Next Major

Comments

@dubzzz
Copy link
Owner

dubzzz commented Dec 26, 2023

馃挕 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: #4570 (comment)

@dubzzz
Copy link
Owner Author

dubzzz commented Jan 2, 2024

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 dubzzz added help wanted contributions welcome good first issue easy issue to start with - contributions welcome labels Jan 2, 2024
@dubzzz
Copy link
Owner Author

dubzzz commented Jan 3, 2024

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
Copy link
Owner Author

dubzzz commented Jan 5, 2024

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

Code of the playground
// Playground to build record

// record in v3+ (v3 with options dropped to be closer to v4)
type RecordValue<T, TConstraints = {}> = TConstraints extends { requiredKeys: (infer TKeys)[] } ? Partial<T> & Pick<T, TKeys & keyof T> : T;
type RecordConstraints<T = unknown> = { requiredKeys?: T[]; noNullPrototype?: boolean; };
declare function record<T>(recordModel: { [K in keyof T]: Arbitrary<T[K]> }): Arbitrary<RecordValue<{ [K in keyof T]: T[K] }>>;
declare function record<T, TConstraints extends RecordConstraints<keyof T>>(recordModel: { [K in keyof T]: Arbitrary<T[K]> }, constraints: TConstraints): Arbitrary<RecordValue<{ [K in keyof T]: T[K] }, TConstraints>>;

// 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() }, { requiredKeys: ["s"] }),
  ""
);
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: ['b', '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', '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<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;

@ssalbdivad
Copy link

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue easy issue to start with - contributions welcome help wanted contributions welcome 鉁旓笍 Idea to investigate 馃挕 Idea 馃挜 Next Major
Projects
None yet
Development

No branches or pull requests

2 participants