typescripttypesnullability

How do you make a function's return type depend on its argument type at all


Let's say I have something like this:

type TIn = {
    xa?: number;
    xb?: string;
    xc?: boolean;
    // ...
}

type TOut = {
    ya: number | undefined;
    yb: string | undefined;
    yc: TPerson | undefined;
    // ...
}

type TPerson = {
    name: string;
    age: number;
}

function fn(input: TIn): TOut {
    // ...
}

Now, let's say I want to enforce the static nullability checking of the output fields against the input. For instance:

And so on.

In C# there are special attributes to instruct the compiler on how to statically check the nullability.

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/nullable-analysis

I wonder if there is any "surrogate" (I mean playing with types and utils) in TypeScript to achieve a more complete nullability checking.

Also, not a "this example" taylored solution, rather a modular tool to use against any case.


EDIT: Here's an attempt to solve, not working, but added for clarity.

These below are the supposed helpers:

//maps TResult when T1 is not undefined
//undefined otheriwse
type TNotNull<T1, TResult> = T1 extends {}
    ? TResult
    : undefined;

//maps TResult | undefined when T1 is not undefined
//undefined otheriwse
type TMaybeNull<T1, TResult> = T1 extends {}
    ? TResult | undefined
    : undefined;

//maps TResult when both T1 and T2 are not undefined
//undefined otheriwse
type TNotNullWhenBoth<T1, T2, TResult> = T1 extends {}
    ? T2 extends {} ? TResult : undefined
    : undefined;

There could be many more (more args, more complex logic, etc)

Now, as example, let's define the input contract of the function:

type TIn = {
    a?: number;
    b?: number;
    s?: string;
}

For the output type, the idea is to compose it using the supposed helpers:

type TOut<T extends TIn> = {
    inv_a: TNotNull<T["a"], number>;
    flag: TMaybeNull<T["b"], boolean>;
    str: TNotNull<T["s"], string>;
    sum: TNotNullWhenBoth<T["a"], T["b"], number>;
}

That is, for inv_a:

For flag:

For str, similarly as inv_a:

For sum, same logic as inv_a, but two args AND-ed:

Finally, let's write the function. Here the implementation is not important. Outside the function, we only need the input/output contract be always satisfied.

const flags: Array<boolean> = new Array(10);
flags[3] = true;

function fn(input: TIn): TOut<TIn> {
    const { a, b, s } = input;
    //just an example of implementation
    const inv_a = typeof a === "number"
        ? 1 / a
        : void 0;
    const flag = typeof b === "number"
        ? flags[b]
        : void 0;
    const str = typeof s === "string"
        ? s + "hello!"
        : void 0;
    const sum = typeof a === "number" && typeof b === "number"
        ? a + b
        : void 0;
    return { inv_a, flag, str, sum }
}

At this point, here are some usage cases and how the result inference are expected:

//expected inference: all undefined
//actual inference: inv_a:number|undefined; flag:boolean|undefined; etc
const { inv_a, flag, str, sum } = fn({});

//expected inference: flag:boolean | undefined; rest undefined
//actual inference: inv_a:number|undefined; flag:boolean|undefined; etc
const { inv_a, flag, str, sum } = fn({ b: 3 });

//expected inference: inv_a:number; str:string; flag, sum:undefined
//actual inference: inv_a:number|undefined; flag:boolean|undefined; etc
const { inv_a, flag, str, sum } = fn({ a: 5, s: "xyz" });

//expected inference: inv_a, sum:number; flag, str:undefined
//actual inference: inv_a:number|undefined; flag:boolean|undefined; etc
const { inv_a, flag, str, sum } = fn({ a: 5, b: 3 });

Unfortunately, it doesn't work.


Solution

  • Thanks to jcalz (see question's comments), who suggested me the proper way.

    My attempt was correct, but it missed of a fundamental thing: instruct the input/output dependency by declaring the function as generic.

    So that will work:

    declare function fn<T extends TIn>(input: T): TOut<T>;
    
    
    {
      //expected inference: all undefined
      const { inv_a, flag, str, sum } = fn({});
    } {
      //expected inference: flag:boolean | undefined; rest undefined
      const { inv_a, flag, str, sum } = fn({ b: 3 });
    } {
      //expected inference: inv_a:number; str:string; flag, sum:undefined
      const { inv_a, flag, str, sum } = fn({ a: 5, s: "xyz" });
    } {
      //expected inference: inv_a, sum:number; flag, str:undefined
      const { inv_a, flag, str, sum } = fn({ a: 5, b: 3 });
    }