Skip to content

Instantly share code, notes, and snippets.

@shicks
Last active August 14, 2023 03:41
Show Gist options
  • Save shicks/219a081b74df7ad28e683761f51102f1 to your computer and use it in GitHub Desktop.
Save shicks/219a081b74df7ad28e683761f51102f1 to your computer and use it in GitHub Desktop.
My TypeScript generics wishlist

Background: TypeScript Generics Wishlist

There are a handful of issues that are both separate features, but are also loosely related in that they speak to certain problematic aspects of generics today. In particular,

  • #7061 (specifically later in the thread) asks for a way to assign a private alias to a complex typed derived from the actual type parameters, usable from within the class/function signature. There are suggestions to use namespaces and other hacks, along with explanations of why <T, U extends Foo<T> = Foo<T>> is insufficient.
  • #26242 and #16597 both pertain to partial inference of type parameters. The rough summary is that you can write <T, U = Foo>, but there's no way to explicitly specify T and still get inference on U. If you omit the <> entirely, then both T and U are inferred and the = Foo initializer is unused. If you specify a <T>, then U is initialized directly from the = Foo initializer without any inference happening. There are many dimensions to this question, including where exactly the inference should be opted into.
  • #40468 and #23689 pertain to some way to do additional validation of type parameters, beyond what a simple lower bound can achieve, and to give understandable/actionable error messages when those constraints are violated.

In recent versions, we've seen a handful of new keywords making their way into type parameter lists: <in T> and <out T> as explicit variance annotations for classes/interfaces, and a proposed new <const T> to hint to the inferencer that the most specific (cf. as const) possible type should be used. I propose a few more additions that would address the above issues. They could be taken separately, but also work in synergy.

Proposals

private

This would solve #7061 by allowing a declaration to declare a handful of private type parameters. The linked issue notes that there are syntactic concerns with trying to specify these types anywhere else (i.e. some proposals to add an extra block somewhere between the declared name and the definition/body, but this is awkward and would be necessarily different for functions vs. classes, at least). There are also concerns with whether these types would be exported as general "associated types" accessible from outside the class definition (or function signature+body), which starts getting into generic namespaces (#19728). The use of private neatly avoids this concern by making it pretty clear that these type aliases cannot be accessed outside the class/function.

Example

function makeTable<
      R, C, V,
      private Table = Map<R, Map<C, V>>
    >(rows: R[], cols: C[], init: (r: R, c: C) => V): Table {
  // ... (type `Table` available in scope here, too)
}

See also #18074 for some slightly better motivated examples.

This is almost workable today, in that you can write this without the private keyword and it works as long as (1) you specify R, C, and V explicitly, (2) you don't pass a fourth parameter, and (3) you don't need strict type equality. If you omit the explicit types, then (today, without private) Table is inferred rather than initialized with the specified initializer, and thus the following bad code compiles:

const table: Map<string, Map<string, 42>> = makeTable(['x'], ['y'], () => 0);

This is surprising, since table.get('x')?.get('y') is actually 0, but the type checker thinks it's 42. This happened because the inferencer inferred a subtype for Table, rather than the specified type. A caller could also explicitly specify whatever type they wanted (again, subject to any lower bound), for a similar effect. The issue about strict type equality shows up if Table were used in a contravariant context, since the type checker can only guarantee that Table is a subtype of the specified lower bound: this makes it impossible to assign a Map<R, Map<C, V>> to a variable of type Table, since the type checker understands the first half of this paragraph and thus knows that it could require any subtype.

A more robust workaround is to (1) add an impossible to satisfy type parameter and check that it was not specified as never or any, or (2) wrap the aliased type in an invariant wrapper so that subtypes can't be inferred/specified. But these are awkward, inefficient, and can make inference more brittle.

Semantics

  • Private type parameters must occur at the end of the parameter list: no non-private parameter may follow a private parameter.
  • Private type parameters must be initialized (i.e. as private Foo = ...). This fits well with them apearing after all non-privates, since non-privates can include initialized parameters, so a non-initialized private would be a problem.
  • Callers may not specify an explicit type for the parameter, and no inference occurs for them: they are an exact type alias available only within the definition (i.e. function signature/body, class body and methods, etc).

infer

This speaks to #26242 and/or #16597, which look at two sides of the partial inference question. In particular, there are two options for who requests the partial inference: (1) the declaration, or (2) the caller.

Aside: This is analogous to the two styles of variance commonly seen, e.g. between Java and Kotlin. Java uses call-site variance, such that covariant types like Future<T> show up as Future<? extends T> in nearly every signature, while contravariant types like Predicate<T> show up as Predicate<? super T> everywhere. This allows finer-grained control and flexibility (e.g. when an invariant type is being used covariantly), but is also very easy to get wrong. Kotlin fixed this by introducing declaration-site variance: allowing classes to be annotated as <in T> or <out T>, which was the inspiration for the same feature in TypeScript (though unlike Java, TypeScript already had reasonably good, if inefficient and harder-to-debug, inference for this).

We could easily support both/either declaration-site and/or call-site inference requests. Like the variance aside, call-site allows more usage flexibility, but does not empower library authors to make safe/expressive APIs as easily.

Example

For the declaration-site version:

function makeGraph<V, N = infer>(edges: Map<N, N[]>): Graph<N, V> { ... }
const g = makeGraph<number>(new Map([[1, [1, 2]], [2, [1]]]));

In this case, we consider a Graph type where elements can have an optional annotation of type V. Without the partial inference, one would have to write one of

const g1: Graph<N, V> = makeGraph(...);
const g2 = makeGraph<V, N>(...);

both of which require duplicating the (potentially complex) N type, or else if N is unspecified then it's initialized to whatever default (possibly just unknown) independent of the already-known type of the map passed to it.

A more compelling example comes up in testing: an expected-type test might want to write

type CheckSame<A, B> = A extends B ? B extends A ? [] : [never] : [never];
function expectType<T, private U = infer>(_arg: U, ...rest: CheckSame<T, U>): void {}
expectType<Promise<number>>(Promise.resolve('x').then(() => 42));

Without the partial inference, the above is impossible to write without an extra artificial curry added to the call (i.e. expectType<Promise<number>>()(Promise.resolve('x').then(() => 42));), which shows that TS is capable of doing the inference, but it forces an unacceptably awkward API.

Note that the private helps prevent misuse (and note also that other "type alias" solutions to the private issue above would not work so well with infer). The [never] approach could further be ameliorated by a later proposal in this document.

Use-site inference could fill in any gaps where an API author didn't specify infer, but it's desired anyway. In this case (if makeGraph weren't written with infer), one might write

declare const [a, b]: SomeComplexTypeWeDontWantToWriteAgain[];
const map = makeGraph<string, infer>([[a, [a, b]], [b, [a]]]);

Semantics

  • This only applies to functions/methods: I believe an inferred type in a class or interface is probably nonsense.
  • If a type parameter is declared as (or initialized to) infer then the type inferences will use inference to initialize it. The use of <T = infer> rather than (say) a prefix <infer T> makes sense because it occurs under exactly the same circumstances as the use of a default initializer today. (I also don't believe it makes sense to combine them: infer T = number is probably nonsense).
  • Inferred type parameters may appear anywhere an optional/initialized parameter can appear.
  • When a parameter is private T = infer, then inference is mandatory: the caller cannot override it. Otherwise, inference only occurs when an explicit type is not specified in the parameter list.

Questions

  • Use-site inference could also work by (1) leaving a hole in the type parameter list (foo<, number>()) or (2) using some other placeholder (_, *, etc); or could be left out entirely.
  • What about inferring the first parameter in a template type list? e.g. for function foo<T = infer, U = number>(), if you call it as foo() then both T and U get inferred (rather than U being initialized to number), but foo<>() is currently invalid syntax.
  • What about infer in a constructor? The class-level type parameter isn't infer, but how to express it on a constructor?
  • Is it even workable to use infer (or any other valid identifier, such as _, which has been suggested for the call-site placeholder) here? What if infer is already defined in scope as a type name? Do we need a non-identifier syntax (i.e. = *) to support this instead? (This would slightly degrade the default initializer analogy but wouldn't change the semantics)

error

#40468 and #23689 ask for some way to get more direct error messages out of various conditions in the type checker.

Example

If I want to make a method that requires passing an enum container, I might write something like

type Guard<T> = (arg: unknown) => arg is T;
type EnumElement<T> = T extends EnumOf<infer E> ? E : error `${T} is not an enum`;
declare function enumMemberPredicate<E>(enum: E): Guard<EnumElement<E>>;

where the details of EnumOf<T> would require a more complicated usage than what's seen here (and is thus out of scope). But the gist remains that we can do some type-level validation and emit a clear error if a given branch of a conditional type is taken.

Semantics

I'm less clear on the exact semantics here. I think the error should be emitted if the type ever shows up outside of a type definition or conditional. So the following would not emit an error:

type Foo<T> = error `foo: ${T}`;
type Okay = number extends string ? error `wat` : boolean;

In the case an error is emitted, the resulting type is indistinguishable from any. I don't believe it's a good idea to allow any sort of recovery or matching on error types, so one could not write

type Caught<T> = T extends error (infer E) ? E : never;

satisfies

This is somewhat of a more limited version of error. Rather than allowing arbitrary errors, it would go a long way to simply provide a weaker version of the extends keyword in type parameter lists, which specifically would not establish an actual lower bound. This would thus allow a cyclic reference to the left-hand side in the expression on the right.

Example

type Guard<T> = (arg: unknown) => arg is T;
type Literal<T> =
    [T] extends [number] ? (number extends T ? never : T) :
    [T] extends [string] ? (string extends T ? never : T) :
    [T] extends [boolean] ? (boolean extends T ? never : T);
function literalGuard<T satisfies Literal<T>>(arg: T): Guard<T> {
  return (actual) => actual === arg;
}

There are numerous workarounds to get a reasonable type error if a literal is not passed. For instance:

declare function literalGuard1<T>(arg: T extends Literal<T> ? T : never): Guard<T>;
declare function literalGuard2<T>(arg: T, ...rest: T extends Literal<T> ? [] : [never]): Guard<T>;
declare function literalGuard3<T>(arg: T): T extends Literal<T> ? Guard<T> : 'Whoops, pass a literal';

but these do not provide reasonable error messages, and/or fail much later, probably far away from the actual problemmatic code. Replacing satisfies with extends in the example is an error because it results in a circular reference (since extends establishes a lower bound to be used as a placeholder for the free type variable).

Note that this can play quite nicely with infer and private above.

Semantics

  • satisfies would perform the same bounds checking (and type errors) as extends, but it would not establish a lower bound or make any further guarantees about the type itself.

Questions

  • Should the behavior be the same as extends (i.e. LHS must be a subtype of RHS) or does it make sense to look for a keyword that better expresses the idea of a predicate? I.e. we could have a type that returns true or false types instead. While having the type itself specifically be a subclass isn't actually helpful since there's no actual lower bound, I believe it's still the best option since the conditional aspect of it is very well-defined.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment