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:
when xa
is a number
, the related ya
will be also a number
;
when xa
is undefined
, ya
will be also undefined
;
when xb
is a string
, the related yb
can be a string
or undefined
;
when xb
is undefined
, yb
will be also undefined
;
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
:
a
is a number, inv_a
is also a numbera
is undefined, inv_a
is also undefinedFor flag
:
b
is a number, flag
is either a boolean or undefinedb
is undefined, flag
is also undefinedFor str
, similarly as inv_a
:
s
is a string, str
is also a strings
in undefined, str
is also undefinedFor sum
, same logic as inv_a
, but two args AND-ed:
a
and b
are numbers, sum
is also a numbersum
will be undefined otherwiseFinally, 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.
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 });
}