Typescript: Exact generic constraints

Created on 29 Dec 2019  Β·  11Comments  Β·  Source: microsoft/TypeScript

Search Terms

  • Generics
  • Constraints
  • HOCs
  • Injected types

Suggestion


I would like to write a function that wraps a child function and provides a subset of its required arguments.

Here's an example: https://www.typescriptlang.org/play/index.html#code/PTAEGMAsEsBsBMAUBKUAnApgRwK7UwM6jwCGALiaNAHbiw7w0DmoABia6CdfGwEasAdACgAZjlploAe2oQYCRKQoAuUAG8uagmTTMANKD7bdzUAF9kagG7TovdcNDOIsgtNgZBsaUyXkSQRJDZUC+ZGFzAG5hYRBQSGlwFFACHAAHdNhoDCJ2VkNuXkwyHDRqIkpxSRk5MkhyUFlYAE90bDxCfiFhMhb0jFAAQXhGKVlQAF4NLVTTahZosQlwcblE8AAeABVQDAAPMgweIhGx2oA+RCg4eDV-VVBt1EmL0Ft7K1AHkjUAeQAttAyDtDABrDAtaSiYajYGXF5vD4OJwuaqrWqgADuaBImQwSFC-yBIO24Mh0Nh51kF1QjhcDPkt0QmkEbNChTUAHI8VkMFyLMgYgzzLEGSUynIcbyCTFRcJwG4yNjcfjeNMNtcFPAhcJpWqWUZuXxpC0BZYokA.

However, this errors out because the child function's generic argument H could be defined to be _more_ specific than just a: string (for example, it could be defined to be a: 'foo' | 'bar'). This is covered here: https://github.com/Microsoft/TypeScript/issues/29049, and explained here: https://stackoverflow.com/a/56701587.

It would be useful to have a way to set a constraint on the generic H where it _must_ contain exactly the fields that it is "extend"ing from. For example:

// Note that `contains` here is a made up new term. We can choose another name if preferable.
const hoc = <T contains {a: string}>(child: (arg: T) => void): (arg: Omit<T, 'a'>) => void => {
  const wrapper = (arg: Omit<T, 'a'>) => {
    child({ ...arg, a: 'apple' });
  };
  return wrapped;
};

hoc((arg: {}) => {}); // Error, arg does not contain {a: string}
hoc((arg: {a: string}) => {}); // Works!
hoc((arg: {a: string, b: string}) => {}); // Works!
hoc((arg: {a: 'foo' | 'bar'}) => {}); // Error, a must be `string`, not `'foo' | 'bar'`.

I don't fully understand why, but a similar example in Flow works without issue (possibly due to the use of exact types?): https://flow.org/try/#0PQKgBAAgZgNg9gdzCYAoVxhgMYAsCWMAJgBQCUYATgKYCOArvjQM5hECGALu2PgHbYY9IvwDmYAAbsJYdnyKSARhIB0qKPQGd8cPjgLESHbgC4wAbwA+ss806UxAGjCLb9sWEsBfMmYBucPgK5qhgYTi6zHAw1CrwokZc7CrszsbJimSoXgDc6JhguHDY5GDM9AAOFTD41KxSEs5yCjSc9JR8rDwaWjp6nLhcYLowAJ5UdIwsSqrqmtjauoXFADwAKmbmNmXufOJeAHwkeIREZommYGsUALwHYAFBvmAX7GYAJAAi+FBQ685WbZ2Bx7TyHW73R7BULhHoLPpgBCUdhVaikdIfb6-f4WaxvHYg8TeA4UELhcn6U4kLYqWnpJpmADkKOq1EZYB8eXJXnQ5Na7T0SJZaLyPNQ2EinERyNRChuyxKJ2IZDyQtl1NcYEZijgo0ZnKAA.

Use Cases

My motivating use case is building a well-typed middleware framework. Middleware often add fields to the context of an HTTP request before or after the handler is called. I would like for a Middleware function to exactly specify which fields it adds to the handler. Applied to the playground code above, child is the HTTP handler, and hoc is one such middleware function.

Examples


As above.

Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

    • In particular, this helps make TypeScript more composable, as per "Produce a language that is composable and easy to reason about.".

Discussion

Most helpful comment

Your solution probably sits somewhere between #14520 and #12936

All 11 comments

Why use generics when you are not using the type parameter?

const hoc = (child: (arg: {a: string}) => void): void => {
  child({ a: 'apple' });
};

@AnyhowStep The code sample was a reduced example. If you click through to the playground, it shows a more meaningful example where the type parameter is being used.

Update: I updated the code sample to include use of the type parameter.

I've got a workaround but it isn't the prettiest,
Playground

Your solution probably sits somewhere between #14520 and #12936

Re: #14520

{} is a super type of { a : string }. So, <T super Addition> wouldn't work for this case.


Re: #12936

Most people think of "exact types" as having exact properties, but not exact types for values.

So, { a : "hi" } would still be assignable to Exact<{ a : string }>

{} is a super type of { a : string }. So, wouldn't work for this case

You can have upper and lower bound constraints on a parameter

Alternative solution, still requires a cast unfortunately. Playground

// child() requires data including `a` and `b`.
function child(data: { a: string, b: string }): void {
    console.log(data.a, data.b)
};

// Merge two objects, taking types from U if possible, and falling back to T
type Merge<T, U> = {
    [K in keyof T | keyof U]: K extends keyof U ? U[K] : K extends keyof T ? T[K] : never
}

// Remove props in T that are also in U
type RemoveCommonProps<T, U> = Pick<T, Exclude<keyof T, keyof U>>

// hoc() supplies `a`, and returns a function that only requires `b`.
type Addition = { a: string };

function hoc<T extends Addition>(child: (data: Merge<T, Addition>) => void) {
    function wrapped(data: RemoveCommonProps<T, Addition>) {
        // Still requires a cast unfortunately
        child({ ...data, a: 'apple' } as Merge<T, Addition>);
    }

    return wrapped;
}

const wrapped = hoc(child);

wrapped({ b: 'boy' });
wrapped({ b: 'boy', a: 'a' }) // Error, provided by hoc (freshness check)

const data = { a: 'a', b: 'b' }
wrapped(data) // No error because object literal is not fresh

35899

On Sun, Dec 29, 2019, 11:37 AM Gerrit Birkeland notifications@github.com
wrote:

Alternative solution, still requires a cast unfortunately. Playground
https://www.typescriptlang.org/play/index.html?ssl=1&ssc=1&pln=32&pc=62#code/PTAEGMAsEsBsBMAUBKUAnApgRwK7UwM6jwCGALiaNAHbiw7w0DmoABia6CdfGwEasAdACgAZjlploAe2oQYCRKQoAuUAG8uagmTTMANKD7bdzUAF9kagG7TovdcNDOIsgtNgZBsaUyXkSQRJDZUC+ZGFzAG5hYRBQAFkMNCYMUDIAd2lQaT4AKwxwMgJDCgBrMzIATwAHDCJRNGkAW1AAVSpRUBrpAgJoPk9Dbl5RElhYMz4ScDL07IAVYWq6xOTUgB4FwzaAPlAAXg0nFwBtAGkqOTKMKukuhdAAH1Abu662gF01S4wADzIGB4RDe93aoAA-O0Lp9QD9QP9AcDXrcwY8oQsYXDQNQMNZkpFYvEAEoYZrSfHdJo1Ig0UCPMiQchcTBcWDuK7tZa1NKk8n4gDCLXJ1AACtSCFsdvsjqLoLMpaAAKJ-OgMDAbUEPQxa9q7XZEsCQaTgFCgAg4Go1Sb1NgcYY8dAYMg4NDUIiUcSSGRyRnM2SwKpO3D4W2sAQiFZpACC8EYUlkhw0WnNpmoLGisS9RR9oGN4C2CIBQPgRFj8Z9u0QUDg8DU-lUaxSGu2oHL0AT1F2qAO+1s9lQjhcoGzndAGTQJCtGCQoTUfIpGCFzRF4ukNMV7c73eOw+H8QAylIJsG8IQuBASDpQBJRNI0C7qOQMIGTnv5LXEJpBD-QsM1AA5FO1oYABFhcEQSTNpucYdpWyAxMO5ixMOmAum646TtO8AxMhwjgG4ZCYcBM5Jvm1YKPACGxBOJFIJoxigABfDSFUYGWDEtHYV+RiASxbH-kxJDsag8RKmgTRoIYNRNNY9ikXwQb5qAiCNPUkC4n08iFGUET4YRxABEmmgkIBwmGIxzHscIXF1LOASiWAABy2TJJJRiFCQOAEGkuQFEUoCTICk6wFQRDUNIRFqQQkBAA

// child() requires data including a and b.function child(data: { a: string, b: string }): void {
console.log(data.a, data.b)
};
// Merge two objects, taking types from U if possible, and falling back to Ttype Merge = {
[K in keyof T | keyof U]: K extends keyof U ? U[K] : K extends keyof T ? T[K] : never
}
// Remove props in T that are also in Utype RemoveCommonProps = Pick>
// hoc() supplies a, and returns a function that only requires b.type Addition = { a: string };
function hoc(child: (data: Merge) => void) {
function wrapped(data: RemoveCommonProps) {
// Still requires a cast unfortunately
child({ ...data, a: 'apple' } as Merge);
}

return wrapped;

}
const wrapped = hoc(child);
wrapped({ b: 'boy' });wrapped({ b: 'boy', a: 'a' }) // Error, provided by hoc (freshness check)
const data = { a: 'a', b: 'b' }wrapped(data) // No error because object literal is not fresh

β€”
You are receiving this because you are subscribed to this thread.
Reply to this email directly, view it on GitHub
https://github.com/microsoft/TypeScript/issues/35899?email_source=notifications&email_token=AAXC6EL3FQWB7WVUBOVQQUDQ3DG4RA5CNFSM4KAZBLTKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEHZDJVY#issuecomment-569521367,
or unsubscribe
https://github.com/notifications/unsubscribe-auth/AAXC6ELJOFNJLW75SV2QXVLQ3DG4RANCNFSM4KAZBLTA
.

I think @Gerrit0's solution is reasonable. If I understand correctly the errors (or lack-of) at the call to wrapped are less important because the spread overrides correctly.

Expecting an error for a call like hoc((arg: {}) => {}); might not be right because a function of that type can be safely used at types with more specific arguments like (arg: {a: string}) => {}.

Tagging this Discussion since I think there are a variety of good approaches on the table already, plus various other proposals linked to would cover this if implemented

I ran into this same issue, posted on SO, and was very surprised that the response was there's no way to require an exact match. +1 for this feature.

Was this page helpful?
0 / 5 - 0 ratings