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

Suggestion: Provide a way to force simplification/normalization of conditional and mapped types #47980

Open
5 tasks done
josephjunker opened this issue Feb 20, 2022 · 8 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

@josephjunker
Copy link

josephjunker commented Feb 20, 2022

Suggestion

πŸ” Search Terms

simplification
normalization
normal form
eager evaluation

The closest I found to this was #20267

Eager
normalize
normalization
simplify

βœ… Viability 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, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

On the design goal point, I believe this feature would further goals 5 and 9:

  • Produce a language that is composable and easy to reason about.
  • Use a consistent, fully erasable, structural type system.

Elaboration on this point is below.

⭐ Suggestion

Add a way to force eager evaluation of mapped and conditional types, or a way to force evaluation of a type until it reaches a sum-of-products normal(ish) form. This would lead to simpler inferred types from a user's perspective when type algebra is in play, as well as more terse and focused error messages.

I think there are multiple ways this could be done. There could be an eager keyword which, when attached to a conditional or mapped type, causes that type to be evaluated as soon as possible, discarding intermediate type information. It could be a baked-in "utility type", like Simplify<T> or Simplify<T, depth extends number> which, when applied to a type, forces the evaluation of that type (until it reaches a sum-of-products form, or for a given number of steps).

πŸ“ƒ Motivating Example

Here is a small example of a case where a utility type leads to much less useful intellisense information than would be present with a manually-written type. My proposal here would be a way to force the type AtLeastOneBar, which has inferred type

 (Required<Pick<Bar, "one">> & Partial<Pick<Bar, "two" | "three">>) | 
 (Required<Pick<Bar, "two">> & Partial<...>) | (Required<...> & Partial<...>

to simplify to the equivalent type,

{
  one: number,
  two?: number,
  three?: number 
} | {
  one?: number,
  two: number,
  three?: number
} | {
  one?: number,
  two?: number,
  three: number
}

This case may be a matter of personal taste, but I chose it because it is comparatively simple. It may be that users would prefer to prevent any expansion of this type at all, and have the following inferred type:

RequireAtLeastOne<{
  one: number,
  two: number,
  three: number
}>

I think this difference in preferences is a valid point of discussion, because it's also on the topic of "how can library authors exercise more control over the types produced by their utilities?"

Here is a much more advanced example, and a case where I don't think the need for simpler types is as subjective. This is a simplified excerpt from a library which I am building, which aims to do a similar job to Runtypes/Zod/etc., but with extra extensibility. The type algebra in the library is hairy, and I do not know whether I will complete it because the complexity of the inferred types is too high for the library to be practically useful.

In this example, I define classes to describe schemas at runtime and link them to TS types. I show two types, which are equivalent. (They are subtypes of each other.) One is the manually created type,

{
    a: boolean,
    b?: boolean
}

and one is the library-inferred type

Partial<Unwrap<{
    a: Schema<boolean, {}>;
    b: Schema<boolean, Record<"optional", true>>;
}>> & Required<Pick<Unwrap<{
    a: Schema<boolean, {}>;
    b: Schema<boolean, Record<"optional", true>>;
}>, "a">

The inferred type leaks implementation details to all of the library's users. It makes the types way harder to understand and leads to intimidating type errors. The goal of this issue is to provide a way to make the actual type normalize down to the desired type inside of the library.

πŸ’» Use Cases

I believe this feature would address a common complaint about use of TS's advanced features: "Avoid type algebra because it adds complexity and is hard to reason about". From the typechecker's perspective, types compose. From a user's perspective, reasoning about types is not compositional due to progressively increasing complexity. It's pretty easy to use type algebra to make several types which are comprehensible on their own, but which are incomprehensible when composed. As a result, some users (very persuasively) advise against using type algebra anywhere, because doing so can lead to complexity which ripples across a codebase.

In my opinion the problem is that it's not possible to write abstractions which encapsulate the complexity of type algebra. The situation is like if JS was lazily evaluated, and if using console.log on a value for debugging printed out a long series of thunks reflecting all of the computations which lead to that value instead of the value itself. If we could force type algebra to be simplified, it could make type-level programming easier to debug and maintain, and it would allow library authors to hide the fact that type algebra is occurring at all from end-users.

@MartinJohns
Copy link
Contributor

Sounds like a duplicate of #34556.

@RyanCavanaugh
Copy link
Member

This (at time of writing) works:

type Expand<T> = T extends unknown ? { [K in keyof T]: T[K] } : never;
type Out = Expand<AtLeastOneBar>;

@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 Feb 23, 2022
@josephjunker
Copy link
Author

Oh neat, I didn't expect it to be so easy. I verified that it works for my more complex example as well.

Is there any guarantee that this will continue to work in the future? And would there be any appetite to make it a built-in utility type, to ensure that it does?

@RyanCavanaugh
Copy link
Member

We don't provide any guarantees on "type representation". It's sometimes necessary to change the simplification rules (in either direction) for performance or correctness reasons.

@josephjunker
Copy link
Author

I guess that what I'm asking for is for there to be a way to override that, then. My reasoning is that the representation of types constitutes the API of libraries, so not having any control over representation means that libraries aren't in control of their own APIs. I definitely understand if this is infeasible or unneeded by most developers, but I want to raise it as an issue in case anyone else struggles with it.

@ModPhoenix
Copy link

If needed a recursive version and a real-world example, look here: Playground

/**
 * Force expand (simplification/normalization) of conditional and mapped types.
 * More info {@link https://github.com/microsoft/TypeScript/issues/47980 TypeScript/issues/47980}
 */
export type Expand<T> = T extends unknown
  ? { [K in keyof T]: Expand<T[K]> }
  : never;

And I have a suggestion to add such util to the default Utility Types, if not planned to solve this issue in the compiler.

@gwhitney
Copy link

gwhitney commented Aug 18, 2023

Is there a trick like RyanCavanaugh's or @ModPhoenix's deeper version thereof that works for function types? I have a type that's currently being reported as:

<T>(dep: Dependencies<"op", T>): (a: T) => T

but I know that it is really

<T>(dep: {op: (a: T, b: T) => T}): (a: T) => T

and I would like it to be described/reported as such if possible. Say my type is Foo. Intuitively I want something like

(pars: Expand<Parameters<Foo>>) => Expand<ReturnType<Foo>>

but that produces

(pars: [dep: { op: {}; }]) => {}

where I have lost the genericity and the nested function types. I can imagine fixing the latter problem by enhancing the definition of the Expand generic to first test if T extends Function and recursing on Parameters and ReturnType if so, but I am unclear about how to handle genericity: some of the function types I want to normalize in this way are generic and some are not. Even if there is an extends test for genericity (is there?) how will I know how many type parameters to supply, etc? Any guidance would be appreciated. It certainly would be nice to have a straightforward, reliable way to canonicalize an arbitrary type, at least to a reasonable extent.

@gwhitney
Copy link

gwhitney commented Aug 19, 2023

Well, in the case that my type Foo is known to be a one-parameter generic, I finally managed to put something together that seems to work OK. It's a bit involved, so I thought I'd post it here:

type AsArray<T> = T extends any[] ? T : [];
type DeepExpand<T> =
   T extends (...args: any) => any ? (...pars: AsArray<DeepExpand<Parameters<T>>>) => DeepExpand<ReturnType<T>> :
   T extends unknown ? { [K in keyof T]: DeepExpand<T[K]> } : never;
type Out = <T>(...args: DeepExpand<Parameters<Foo<T>>>) => DeepExpand<ReturnType<Foo<T>>>

Repeating the Parameters ... ReturnType breakdown at the top level and inside the DeepExpand is unfortunate, but I couldn't find a way to avoid it, given the need to have Out be generic to preserve the genericity of Foo. Anyhow, this produces

<T>(dep: { op: (a: DeepExpand<T>, b: DeepExpand<T>) => DeepExpand<T>; }) => (a: DeepExpand<T>) => DeepExpand<T>

which makes the structure of Foo very clear (although it would be nice to erase the DeepExpands that have pushed their way all the way down to the "T-leaves". But for non-generics (and two-parameter generics, etc.) I would need to use other type expressions. I asked on StackExchange, and jcalz, who has struck me as very knowledgeable, responded that he didn't think there there was a type expression I could use to conditionally select based on whether the input type Foo was generic or not. To my mind, the fact that doing this is so intricate and doesn't seem as though it can be made fully automatic and reliable for all types suggests that a standardized mechanism to request simplification/normalization of an arbitrary type would be very valuable. Thanks for considering.

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