Skip to content

Allow defering type check of default value for generic-typed function parameter until instantiation #58977

Open
@allisonkarlitskaya

Description

@allisonkarlitskaya

🔍 Search Terms

TS2322, generic-typed function, default parameter

✅ Viability Checklist

  • 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 isn't a request to add a new utility type: https://github.com/microsoft/TypeScript/wiki/No-New-Utility-Types
    This feature would agree with the rest of our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals

⭐ Suggestion

I've read #49158 but I think my motivating example (and suggested solution) are different enough that it makes sense to open a new issue.

In essence, I think it would be nice to have a way to avoid TS2322 ("Type 'X' is not assignable to type 'Y'. 'Y' could be instantiated with an arbitrary type which could be unrelated to 'X'.") on default function parameters by deferring the check until the call site. In essence: at each caller, the caller is obligated to do one of the following:

  • instantiate the generic with a type (possibly inferred) which permits the default value to the parameter; or
  • provide a different value for the parameter which does satisfy the user's chosen type

It would even be reasonable to require that the default value of the parameter would allow the type parameter to be inferred (ie: so that "naive" uses of the call without specifying the type parameter nor the function parameter) would be valid.

Here's a very simple idea of how the feature would work:

// function definition (currently a violation of TS2322)
function x<T extends boolean>(arg1: T = false): T {
    return arg1;
}

// call site
x(); // OK: return type is false
x(true); // OK: return type is true
x<false>(); // OK
x<false>(false); // OK
x<true>(true); // OK

x<true>(); // must not be OK — I want the compiler to give me an error here, instead of at the site of the function definition.

Note in particular that it's not possible to use optional parameters to accomplish the above. We need the x<true>() case to fail, because the default value is `false, after all.

📃 Motivating Example

Cockpit is an interface for performing admin tasks on Linux servers. We essentially allow the Javascript running in the browser to interact with a set of APIs on the server via "Channels". There's channels for reading files, creating files, connecting to sockets, spawning commands, making D-Bus calls, etc. Channels can be opened in binary or text mode, which is controlled by passing { binary: true } as part of the options object passed when creating a given channel.

The various channel subtypes have higher-level APIs which wrap the raw channel implementation. Those in turn are sometimes wrapped by even higher level APIs which pass through the options object. For example, cockpit.spawn() will spawn a command, and we have a wrapper cockpit.python() which will pass a given Python script via the -c argument and capture the result.

In most of these "wrapping" cases, the presence/value of the binary option on the options object will impact the return type of the function.

At first we did something like this:

    function spawn(
        args: string[],
        options?: SpawnOptions & { binary?: false }
    ): ProcessHandle<string>;
    function spawn(
        args: string[],
        options: SpawnOptions & { binary: true }
    ): ProcessHandle<Uint8Array>;

which produced the correct effect. The options object is mandatory if you want binary, and it must contain binary: true.

It's very difficult to "wrap" such APIs, though: each wrapper needs to provide its own set of overloaded definitions, and convincing TypeScript that the internal call (to the "next layer") is correct is very difficult.

More recently I've come up with something like this simplified example, which works reasonably OK:

interface Channel<T> {
    get(): T;
}

type Opts = {
    binary?: boolean
}

type Payload<T extends Opts | undefined> = T extends { binary: true } ? Uint8Array : string;

function open<T extends Opts | undefined>(options?: T): Channel<Payload<T>> {
    console.log('options', options);
    throw new Error("NotImplementedError");
}

function wrap<T extends Opts | undefined>(options?: T): Channel<Payload<T>> {
    return open(options);
}

// all of these work
export const open_default: () => string = () => open().get();
export const open_string: () => string = () => open({ binary: false }).get();
export const open_binary: () => Uint8Array = () => open({ binary: true }).get();

export const wrap_default: () => string = () => wrap().get();
export const wrap_string: () => string = () => wrap({ binary: false }).get();
export const wrap_binary: () => Uint8Array = () => wrap({ binary: true }).get();

The problem here, though, is that because the options array is optional, you can force a different type for the function and omit the options:

// this should fail, but it doesn't
export const open_inv: () => Uint8Array = () => open<{ binary: true }>().get();

I've gone through a bunch of different approaches to try to figure out a way to approach this but it all comes down to the fact that an optional parameter of the generic type cannot possibly constrain the instantiated type of the function call by its absence. You can always instantiate with your chosen type and the absent parameter will satisfy it (since it's optional, after all).

This is where the idea with default values comes in. I don't actually want the parameter to be optional: I want to specify its default value, and I want that default value to be checked at the call site to match the instantiated type of the function call.

💻 Use Cases

  1. What do you want to use this for?
  • see the motivating example
  1. What shortcomings exist with current approaches?
  • the example I gave is unsound
  1. What workarounds are you using in the meantime?
  • I'll probably use the unsound approach because it more or less works when you don't try to force it to do something wrong

Activity

fatcerberus

fatcerberus commented on Jun 23, 2024

@fatcerberus

Generics aren't C++ templates; there is no template specialization or anything like that. The implementation of a generic has to be valid for all possible types T can be instantiated to, it's by design for the compiler to reject the definition if that's not the case.

jcalz

jcalz commented on Jun 23, 2024

@jcalz
Contributor

Duplicate of #56315 and note that they generally consider two issues duplicates if they both target the same problem, even if they propose different ways to solve the problem.

RyanCavanaugh

RyanCavanaugh commented on Jun 24, 2024

@RyanCavanaugh
Member

I think this is a better write-up than the prior two, so forward-duping.

This is the sort of thing that really does need .d.ts parity to be useful, and the problem here is how to represent this in .d.ts files, where default values aren't manifested. Today if you write the function in the first example, the .d.ts emit is

declare function x<T extends boolean>(arg1?: T): T;

So there's no information that arg1 can't get defaulted when T = true.

Sometimes the default expression is one that can be safely printed in the .d.ts (assuming it were legal), but that isn't always going to be the case -- a default expression can be arbitrary code, after all.

That said, I think non-literal default values are quite rare in practice, and we could maybe get away with just allowing it. We've been reluctant to allow default values in declaration file in parameters because it's something that very much can be out of sync with runtime values in a way that's pretty dangerous, but on the other hand it's pretty useful to be able to see that parseInt's default radix is 10 or whatever, and doing that would let this work too. I'll try to raise it again.

RyanCavanaugh

RyanCavanaugh commented on Jun 24, 2024

@RyanCavanaugh
Member

See also #16665

RyanCavanaugh

RyanCavanaugh commented on Jun 25, 2024

@RyanCavanaugh
Member

Jotting down some raw notes from an earlier conversation

interface AnimalKind {
  cat: { meow: true };
  dog: { woof: true }
}

// Why have a defaulted generic arg? Demo:
function getPet<K extends keyof AnimalKind>(kind: K = "dog"): AnimalKind[K] {
  return null as any;
}
const c = getPet<"cat">(); // no

// How to reason about this example?
declare const aa: true | undefined;
const j = x(aa);
//    ^?
//    j: true, T = true
x(undefined);
changed the title [-]Allow defering type check of default value for generic-typed function parameter until instantaition[/-] [+]Allow defering type check of default value for generic-typed function parameter until instantiation[/+] on Jun 25, 2024
added
Experimentation NeededSomeone needs to try this out to see what happens
and removed on Jun 26, 2024
RyanCavanaugh

RyanCavanaugh commented on Jun 26, 2024

@RyanCavanaugh
Member

Allowing this to get checked at the call site is tricky:

  • For it to have parity with .d.ts (a requirement), we'd need to allow declare function f(n = 0), and if 0 isn't an expression we can write in declaration files, then we'd have to not defer the check. This is inconsistent but probably an explicable inconsistency, and non-trivial initializer expressions are likely quite rare in practice
  • That said, people really want to write f(n = 0) in declaration files for documentation purposes, even if that value isn't "checked" per se
  • Assuming that part is in place, the checking part gets a bit tricky. For something like the j = x(aa) example above, we need to evaluate if it's possible for the default value to be substituted in, not simply check argument arity. This is also has implications on spread arguments, and generic inference

Overall this would likely be a very difficult and invasive PR affecting a pretty broad range of problems - declaration file emit, declaration file parsing, overload selection (!), generic inference, constraint validation, argument validation, and more. It'd be instructive to see a working PR here to evaluate the total trade-off. I think the upsides, especially for #16665, are there, but I don't think something that is several hundred additional lines of code would meet the bar as a feature. If it turns out this can be done simply, though, it'd likely be something we'd take serious consideration of.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Experimentation NeededSomeone needs to try this out to see what happensSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @jcalz@fatcerberus@RyanCavanaugh@allisonkarlitskaya

        Issue actions

          Allow defering type check of default value for generic-typed function parameter until instantiation · Issue #58977 · microsoft/TypeScript