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

Allow inferring generic function types "as const" #38968

Closed
5 tasks done
mmkal opened this issue Jun 7, 2020 · 6 comments
Closed
5 tasks done

Allow inferring generic function types "as const" #38968

mmkal opened this issue Jun 7, 2020 · 6 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@mmkal
Copy link

mmkal commented Jun 7, 2020

Search Terms

const generic inference nested

Suggestion

Let generic function signature specify that type parameters should be inferred as const

Use Cases

Allow writing library functions that specify how they want their generic parameters to be inferred. Right now, this is only possible with generics like <T extends string>(value: T) => ..., which doesn't cover objects - only literals.

Examples

Let's say you have a basic runtime type-matching function:

const getMatcher = <T>(sample: T) => <U>(value: U): value is T & U => {
  // implementation details not important - just a very basic deep-equals
  if (sample && typeof sample === 'object') {
    return Object.entries(sample).every(([k, v]) => k in value && getMatcher(v)((value as any)[k]))
  }
  return sample === (value as any)
}

If I try to use this to make a string matcher, it doesn't keep track of literal types:

const fooMatcher = getMatcher('foo') // T is inferred as `string`, not literal `"foo"`

const s = Math.random() < 0.5 ? 'foo' : 'bar'

if (fooMatcher(s)) {
  const foo: 'foo' = s // Error: Type '"foo" | "bar"' is not assignable to type '"foo"'.
}

This makes sense as default behaviour, since 'foo' was passed in as a string. And it can be fixed at the call site with getMatch('foo' as const). But this only works for typescript programs, and if we're writing a library, javascript users of that library won't get useful type inference. And it's not really intuitive. Even typescript users of the library will need to know somehow that this trick is available.

It could also be fixed by changing the function signature:

const getMatcher = <T extends string>(sample: T) => ...

But getMatcher is now limited to only work with strings. This doesn't cover the case of deeply nested objects:

const commentMatcher = getMatcher({
  type: 'comment',
  payload: {sender: 'abc'},
})

As far as I know, there's no way to tweak the extends constraint for T which makes this keep track of the literal type: 'comment' and sender: 'abc' values. So this feature request is to allow us to effectively ask the compiler to add as const to any inferred type parameters:

const getMatcher = <T as const>(sample: T) => <U>(value: U): value is T & U => {
  ...
}

(syntax is just an idea which reuses the as const pattern in the generic typedef)

If we could do this, it'd make it easy to use the matcher to write useful type guards:

type Request =
  | {type: 'comment'; payload: {sender: string; issueId: number; body: string}}
  | {type: 'issue'; payload: {sender: string; id: number; title: string; body: string}}

const commentMatcher = getMatcher({
  type: 'comment',
  payload: {sender: 'abc'},
})

const handleRequest = (request: Request) => {
  if (commentMatcher(request)) {
    console.log('parent: ' + request.payload.issueId) // request would be correctly inferred as a comment, because `type: 'comment'` was inferred as a literal
  }
}

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Jun 12, 2020
@robbiespeed
Copy link

@mmkal using overloads you can make your getMatcher function infer strings/numbers as const and still work with objects like this:

function getMatcher <T extends string> (sample: T): <U> (value: U) => value is T & U
function getMatcher <T extends number> (sample: T): <U> (value: U) => value is T & U
function getMatcher <T> (sample: T): <U> (value: U) => value is T & U
function getMatcher <T> (sample: T) {
    return function <U> (value: U): value is T & U {
        if (sample && typeof sample === 'object' && typeof value === 'object') {
            return Object.entries(sample).every(([k, v]) => k in value && getMatcher(v)((value as any)[k]))
        }
        return (sample as any) === value;
    }
}

playground

@mmkal
Copy link
Author

mmkal commented Sep 8, 2020

@robbiespeed yes, but I'm interested in inferring objects with string/number properties as literals, not just string/number values. This solution helps, but doesn't cover the example in the OP:

getMatcher({ type: 'comment' })

Here, the generic will be inferred as { type: string }, whereas I want { type: 'comment' }

@robbiespeed
Copy link

robbiespeed commented Sep 8, 2020

@mmkal my mistake I missed the part where it was about properties, doesn't help that I forgot { foo: 'bar' } as const is valid syntax for recursively making a objects properties readonly and inferred as literal.

I did just stumble across how to make it work for properties, I thought it would only restrict the type going into getMatcher to a const, but it actually inferred all the properties as literal.

Edit
I was able to greatly simplify the getMatcher type, it doesn't require overloads, and no longer only accepts literals as the sample or as properties of the sample. Still will infer them as literal if possible. Tested to make sure it worked with the request example you gave in the OP.

Updated

Playground

type ConstRecord <T> = {
  [P in keyof T]:
    T[P] extends string ?
      string extends T[P] ? string : T[P] :
    T[P] extends number ?
      number extends T[P] ? number : T[P] :
    T[P] extends boolean ?
      boolean extends T[P] ? boolean : T[P] :
      ConstRecord<T[P]>;
}

type Const <T> = 
  T extends string ? T :
  T extends number ? T :
  T extends boolean ? T :
  ConstRecord<T>

function getMatcher <T> (sample: Const<T>): (value: unknown) => value is T {
    return function (value: any): value is T {
        if (sample && typeof sample === 'object' && typeof value === 'object') {
            return Object.entries(sample).every(([k, v]) => k in value && getMatcher(v as any)(value[k]))
        }
        return (sample as any) === value;
    }
}

type APIRequest =
  | {type: 'comment'; payload: {sender: string; issueId: number; body: string}}
  | {type: 'issue'; payload: {sender: string; id: number; title: string; body: string}}

const commentMatcher = getMatcher({
  type: 'comment',
  payload: {sender: 'abc'},
}) // infers all nested properties as literal

const fooMatcher = getMatcher('foo') // infers 'foo' as literal

const handleRequest = (request: APIRequest) => {
  if (commentMatcher(request)) {
    console.log('parent: ' + request.payload.issueId) // request is correctly inferred as a comment
  }
}
Old

Playground

type ConstRecord <T> = {
  [P in keyof T]:
    T[P] extends string ?
      string extends T[P] ? never : T[P] :
    T[P] extends number ?
      number extends T[P] ? never : T[P] :
    T[P] extends boolean ?
      boolean extends T[P] ? never : T[P] :
      ConstRecord<T[P]>;
}

type ConstValue <B, T extends B> = B extends T ? never : T;

function getMatcher <T extends string> (sample: ConstValue<string, T>): <U> (value: U) => value is T & U
function getMatcher <T extends number> (sample: ConstValue<number, T>): <U> (value: U) => value is T & U
function getMatcher <T extends boolean> (sample: ConstValue<boolean, T>): <U> (value: U) => value is T & U
function getMatcher <T> (sample: ConstRecord<T>): <U> (value: U) => value is T & U
function getMatcher <T> (sample: ConstRecord<T>) {
    return function <U> (value: U): value is T & U {
        if (sample && typeof sample === 'object' && typeof value === 'object') {
            return Object.entries(sample).every(([k, v]) => k in value && getMatcher(v as any)((value as any)[k]))
        }
        return (sample as any) === value;
    }
}

const oMatcher = getMatcher({ foo: true } as const); // type infered as { foo: 'bar' }
const oMatcherB = getMatcher({ foo: true }); // type also infered as { foo: 'bar' }
const oMatcherC = getMatcher({ foo: { bar: true } }); // works on nested props too!

const b = Math.random() < 0.5 ? { foo: true } : 'bar';

const barMatcher = getMatcher('bar');

if (oMatcherB(b)) {
  b; // type guarded to { foo: true }, although it's not properly reducing the type and has a full type of:
    // ({
    //     foo: true;
    // } & string) | ({
    //     foo: true;
    // } & {
    //     foo: boolean;
    // })
    // It might be possible to fix this with some tweaking
}
else if (barMatcher(b)) {
  b; // type correctly guarded to 'bar'
}

@jcalz
Copy link
Contributor

jcalz commented Sep 28, 2020

Duplicate of or strongly related to #30680

@midzdotdev
Copy link

Yep, const generics have now been implemented. I believe this issue can safely be closed.

#51865

@mmkal mmkal closed this as completed Jan 12, 2023
@mmkal
Copy link
Author

mmkal commented Jan 12, 2023

Great news! Thank you for pointing that out @midzdotdev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants