Skip to content

Introduce "must-infer" generics to restrict user-specified type overrides #59814

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

Closed
6 tasks done
rentalhost opened this issue Aug 31, 2024 · 11 comments
Closed
6 tasks done
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript

Comments

@rentalhost
Copy link

πŸ” Search Terms

must-infer generic
immutable generic
avoid override generic

βœ… Viability Checklist

⭐ Suggestion

TypeScript currently allows developers to specify generic types explicitly, which can override TypeScript’s inferred types. While this flexibility is powerful, there are cases where it is essential to prevent the user from altering the generic type, ensuring that the type inference mechanism retains full control.

I propose adding a "must-infer" modifier for generics, where the TypeScript compiler would infer the type without allowing any user overrides. The syntax could be as follows:

function test<infer X>(value: X): X { 
  return value; 
}

This approach enforces that X is determined solely by the TypeScript compiler based on the input value, and it cannot be explicitly set by the user.

For example:

test<number>(123); // Error: X cannot be set by the user

πŸ“ƒ Motivating Example

This feature enhances type safety by ensuring that the generic type is always determined by TypeScript’s inference system, which is particularly useful in cases where the generic type must match the input type exactly. This prevents potential misuse or errors that could arise from explicitly overriding the inferred type.

πŸ’» Use Cases

In my real-world scenario, I have a method that needs to capture the string representing the name of an attribute and the type of its value. To achieve this, I do the following:

class Example<Attributes extends object = object> {
  private attributes = new Map<string, any>()

  public attribute<Name extends string, Type>(name: Name, value: Type) {
    this.attributes.set(name, value);

    return this as Example<Attributes & { [K in Name]: Type }>;
  }
}

const example = new Example()
  .attribute("user", "John Doe")
  .attribute("age", 33);

// typeof example ~= Example<{ user: string; age: number }>

However, note that I can "subvert" the type by overriding the generic:

.attribute<number | string>("age", 33)

// typeof example ~= Example<{ user: string; age: number | string }>

This is not the expected behavior. Even worse, it allows the user to manipulate my generics, while I intended to use them solely to "pass through" the input type to the output. This restricts future maintenance; if I ever want to change or remove a generic, I wouldn’t be able to do so.

In this case, the proposal would be something like:

public attribute<infer Name extends string, infer Type>(name: Name, value: Type) { ... }

From there, TypeScript itself would infer the types of Name and Type, and the user wouldn’t be able to modify them. This would allow me to maintain the freedom to change my generics without worrying about breaking changes.

My other idea was to simply use typeof, but that doesn’t work well because it expands the type too broadly. For example:

public attribute(name: string, value: any) {
  this.attributes.set(name, value);

  return this as Example<Attributes & { [K in typeof name]: typeof value }>;
}

// typeof example ~= Example<{ [x: string]: any }>

This doesn’t achieve the desired precision, as it leads to overly generalized types.

@MartinJohns
Copy link
Contributor

I don't see the point. You can still change the type by having an explicitly typed variable and pass in the value that way.

const value = 33 as number | string;
foo.attribute("age", value)

@jcalz
Copy link
Contributor

jcalz commented Aug 31, 2024

Please edit to show the actual undesirable case, which looks like .attribute<string, number | string>("age", 33) or attribute<"age", number | string>("age", 33) or something that matches your example.

If I wanted such a thing, I'd probably write something like

public attribute<
  X extends "Please don't manually specify these generic type arguments",
  Name extends string, Type>(name: Name, value: Type) {
  this.attributes.set(name, value);
  return this as Example<Attributes & { [K in Name]: Type }>;
}

which would make any manual specification unlikely to occur unless someone really wanted to (and if they did, they'd probably find some way around any such feature, such as shown in the prev comment).

Playground link

I don't know that the use case shown here is compelling enough for this feature. The only time I've wanted to prevent manual specification of type arguments is when the type parameter has a default for when inference fails (as a workaround for #58977), so you'd get something like

function foo<T = string>(f: () => T = () => ("abc" as T)): T {
  return f();
}

console.log(foo(()=>123).toFixed(2)); // "123.00" πŸ‘
console.log(foo().toUpperCase()); // ABC πŸ‘

console.log(foo<number>().toFixed(2)); // RUNTIME ERROR! πŸ‘Ž

Playground link

But I don't know that this is compelling enough either.

@rentalhost
Copy link
Author

Okay, let me try to explain my use case and the limitation that prevents me from achieving my goal. Maybe it's possible today, and you can help me, but I haven't been able to solve it yet.

TypeScript allows the use of typeof to get the type of a parameter, but it always returns a broad type, not the specific type. Or rather, it will return the type corresponding to the parameter, and not the type of the argument. For instance, if I have something like:

function example(value: unknown): typeof value {
  return value;
}

example(123); // type is unknown
example("abc"); // type is unknown

To address this, we can use generics so that TypeScript can determine the type based on the argument:

function example<T>(value: T): T {
  return value;
}

example(123); // type is number 123, specifically
example("abc"); // type is string "abc", specifically

This is my goal. However, to achieve it, I need to declare generics that can be modified by the user. This adds an extra layer of care when determining how my generics will work in the future. But my intention isn't to make my function flexible enough for the user to decide what to do β€” I just want to use this important TypeScript feature internally.

So, I want to propose the possibility of creating 'invisible' generics, intended for local use and automatically determined by TypeScript (as he already does today, however, in a "transparent way", so to speak).

function example<internal T>(value: T): T {
  return value;
}

example(123); // type is number 123, specifically
example("abc"); // type is string "abc", specifically
example<number>(123); // Error: type T is internal

Why is this important?

I don’t want to worry about how I manage my generics internally in future updates, as I'm intentionally keeping them out of the user's control. I just want to rely on what TypeScript infers.

Maybe I'm talking about an "improved typeof", but I understand the importance of how it currently works.

Essentially, I just need the input type because I need to 'pass forward' exactly the type that was received. This is necessary because I’m building a schema builder that works like this:

const User = schema("user")
  .attribute("name", String)
  .attribute("age", Number)

typeof User.getAttributes() is { name: string, age: number }

To achieve this, .attribute() captures the name (as generic Name) and the type (as generic Type), and then returns this retyped to consider the added attributes. Something like return this as Example<T & { [K in Name]: ...<Type> }>.

However, this forces me to use generics where I don't actually want to use them β€” I only need to know what is being passed in and account for that within the function to return a new type based on the user’s input.


function example<infer N extends string>(name> N): Uppercase<N>; // or
function example(name: infer N extends string): Uppercase<N>; // or

// typeof example("abc") === "ABC"

function example<T>(name: infer N extends string, type: T) { ... }

// example("abc", 123) will have generic T = number, N is internally "string" but not accessible to userland. 

@jcalz
Copy link
Contributor

jcalz commented Sep 1, 2024

Let's say this feature existed. What would you do when someone writes const x: number = 123; example(x); and the "internal" generic is inferred as number and not 123? If it's okay, then why isn't example<number>(123) okay? If it's not okay, then the feature doesn't help you.

@rentalhost
Copy link
Author

@jcalz the goal is to use the same existing feature as generics, but without exposing it directly to the user, since its use would be exclusively internal.

This approach also helps reduce the complexity of the function signature externally. Instead of seeing a signature like example<number>(value: number) (note how number is redundant here), we would just see example(value: number). In this case, number is inferred from const x: number from your example.

So in your example, the type would indeed be inferred as number, not as 123 (which is not the goal anyway).

The main objective, then, is to simply pass along the type received as an argument without needing to expose the existence of a generic, which serves only that purpose.

While generics can solve this, they add an unnecessary maintenance layer in certain cases. For instance, if I define the order as <Name extends string, Type extends JsonValue>, I wouldn’t be able to change it (whether it’s the order, adding new elements before or in between, or retyping it) without causing a breaking change, even though this flexibility was never intended for the user. Think of it as a private method/property in a class.

Using generics as they are today allows the user to provide the generic type themselves. This prevents me from continuing to maintain my function in the intended way. The function only used generics in this manner because it was the only possible way to solve my problem.

@sparecycles
Copy link
Contributor

sparecycles commented Sep 1, 2024

Speaking on motivation, let me chime in with an extension of the example for NoInfer

function createStreetLight<C extends string>(
  colors: C[],
  defaultColor?: NoInfer<C>,
) {
  // ...
}
createStreetLight(["red", "yellow", "green"], "red");  // OK
createStreetLight(["red", "yellow", "green"], "blue");  // Error

Which allows for

createStreetLight<"red"|"yellow"|"green"|"blue">(["red", "yellow", "green"], "blue"); // OK, but... ugh

With the proposed feature

function createStreetLightProposed(
  colors: (infer C extends string)[],
  defaultColor?: C,
)

createStreetLightProposed(["red", "yellow", "green"], "blue"); // Should Error, and there's no <...> way to override

The parameters could still be typecast, ... but this is arguably more natural since the order of the <...> type parameters is no longer an artifact of the API implementation.

type Lights = "red"|"yellow"|"green"|"blue";
createStreetLightProposed(["red", "yellow", "green"] as Lights[], "blue"); // OK

// note that this also works for the existing NoInfer example...
createStreetLight(["red", "yellow", "green"] as Lights[], "blue"); // OK

This feels related to the problem Java solves with ? types. e.g., to deal with the exposed "implementation detail" in the generic type signature.

Interface Collector<T,A,R>

  • Type Parameters:
    T - the type of input elements to the reduction operation
    A - the mutable accumulation type of the reduction operation ⚠️ (often hidden as an implementation detail) ⚠️
    R - the result type of the reduction operation

@MartinJohns
Copy link
Contributor

createStreetLightProposed(["red", "yellow", "green"], "blue"); // Should Error, and there's no <...> way to override

But you can still always do:

const colors: ("red" | "yellow" | "green" | "blue")[] = ["red", "yellow", "green"];
createStreetLightProposed(colors, "blue");

I get the use case to control the public interface to reduce breaking changes when you decide to change the generic arguments, but you can't prevent users from providing their own types.

@sparecycles
Copy link
Contributor

Yep! That's exactly the point that the following as Lights[] example trying to make.

@rentalhost identified a DRY opportunity in the type representation (with maybe a win for locality as well).

I'm actually a little more interested in avoiding <...> for that reason since it encourages types to flow through the parameters themselves rather than function signatures.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Sep 3, 2024

TL;DR we're not going to add something we'd immediately ban on DefinitelyTyped

Inference is inference - it is ultimately guided by heuristics that try to answer the question of what the user wanted the type parameter to be, and absolutely can be "wrong" in the sense of not what the user wanted. If your functions' default inference is "good enough" that the inference is always what the user wanted, then you don't need this feature; users don't write type arguments just to help fill up their hard drives. If the default inference isn't good enough - then what? Forcing the user to upcast or downcast because a library author decided they're not allowed to provide manual type arguments is just making a bad inference someone else's problem, and would rightly be called an antipattern.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Declined The issue was declined as something which matches the TypeScript vision labels Sep 5, 2024
@typescript-bot
Copy link
Collaborator

This issue has been marked as "Declined" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Sep 8, 2024
@NuckChorris
Copy link

NuckChorris commented Jan 31, 2025

@RyanCavanaugh What about a case where one of the generic type params is exclusively used to generate the default for the actual generic parameter? For example:

  route<Path extends string, Params = ExtractParams<Path>>(
    path: Path,
    component: React.ComponentType<Params>,
  ): Route<Params>;

I have a utility type (ExtractParams) which provides a default Params based on the type of the path argument, but there's no reason to ever override the Path parameter β€” it's only used for that default Params type. Because of the Path parameter, though, consumers who wish to override Params have to use it like route<'{/:optional}', { id?: string }>('{/:optional}', OptionalComponent); passing the string to both the generic type and the function call. It's also not possible to invert and structure as route<Params, Path> with consumers ignoring the Path param due to TS2744: Type parameter defaults can only reference previously declared type parameters.

With the proposed concept, I could do something like this:

  route<infer Path extends string, Params = ExtractParams<Path>>(
    path: Path,
    component: React.ComponentType<Params>,
  ): Route<Params>;

// The "Path" parameter is nonexistent
route<{ id?: string }>('{/:optional}', OptionalComponent)

I don't really care if the consumer overrides the Path type manually like route<...>('' as string, OptionalComponent) and gets bad results, I just want my generic type to be ergonomic. An alternative solution for my needs could be some syntax like this:

route<Params = ExtractParams<const typeof path>>(
  path: string,
  component: React.ComponentType<Params>,
): Route<Params>

Grabbing a reference to the const type of the argument without declaring a generic parameter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

7 participants