Welcome toVigges Developer Community-Open, Learning,Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
184 views
in Technique[技术] by (71.8m points)

How to define a conditional return type based on if an object property is set in Typescript

interface Options {
  flagA?: boolean
  flagB?: boolean
}

function abc(name, options?: Options) {}

if(options.flagA) return some type
if(options.flagB) return some some other type

I'd like to have a different return type depending on whether flagA or flagB is set. is this possible?

I've gotten as far as doing something like

function abc<T>(name, options?: T): T extends Options ? string : number { return }
const data = abc('test', { flagA: true });

to return a string or number based on whether not T implements Options, but I'm not sure how to get more specific about checking whether or not options.flagA has a value.


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

For concreteness, I'll define these interfaces:

interface FlagARet {
    a: string;
}
interface FlagBRet {
    b: string;
}
interface NoFlagRet {
    x: string;
}

with the intent that if options has flagA set to true, then abc() will return a FlagARet value; otherwise if options has flagB set to true, then abc() will return a FlagBRet value; otherwise abc() will return a NoFlagRet value.


This sort of type juggling is not something the compiler will do for you automatically, or even be able to verify at compile time; you'll need to spell out to the compiler exactly what type function you'd like to evaluate, as well as do the equivalent of a type assertion to tell the compiler that you are guaranteeing that the function actually returns the type you're claiming. And that is hard to really guarantee. There are likely edge cases, so it's possible that this may be more trouble than it's worth. Be warned.


Below is my attempt at representing the relationship between input and output that takes into account uncertainty in the input type. If, for example, call abc("", {flagA: Math.random()<0.5}, the compiler should return something like FlagARet | NoFlagRet because it can't be sure whether the flagA property will be true or false.

First I make the If type alias which just performs a conditional type check in such a way that it guarantees that it will be distributed across unions.

type If<X, Y, T, F> = X extends Y ? T : F;

Now for the main event:

function abc<T extends Options | undefined = undefined>(name: string, options?: T):
    T extends undefined ? NoFlagRet :
    If<NonNullable<T>['flagA'], true, FlagARet,
        If<NonNullable<T>['flagB'], true, FlagBRet, NoFlagRet>
    >
function abc(name: string, options?: Options) {
    if (options?.flagA) return { a: "ay" };
    if (options?.flagB) return { b: "be" }
    return { x: "ex" };
}

That's a bit involved, but I'll walk through it. First, the generic type parameter T will default to undefined in the case where you don't pass in an options parameter. If T (or any union member of T) is undefined, the output type will be NoFlagRet (or NoFlagRet will be added to the output type via union). Then, we examine NonNullable<T>['flagA']. The type NonNullable<T> makes sure we ignore any undefined part, and then the ['flagA'] type is looking up the flagA property of T. If that flag (or any union member of it) is true, then we return FlagARet (or FlagARet will be added to the output type via union.) Otherwise we examine NonNullable<T>['flagB']. If that flag (or blah blah) is true, then we return FlagBRet (or blah added union blah). Otherwise we return NoFlagRet.

I've written the function as a single-call-signature overload, so that the implementation type checking is looser and I can return a value of type FlagARet | FlagBRet | NoFlagRet without the compiler complaining. Hopefully you can see that the implementation matches the call signature.


Okay, let's test it out:

console.log(abc("", { flagA: true }).a.toUpperCase()); // AY
console.log(abc("", { flagA: true, flagB: true }).a.toUpperCase()); // AY
console.log(abc("", { flagB: true }).b.toUpperCase()); // BE
console.log(abc("", {}).x.toUpperCase()); // EX
console.log(abc("", { flagA: true, flagB: false }).a.toUpperCase()); // AY
console.log(abc("", { flagA: false, flagB: true }).b.toUpperCase()); // BE
console.log(abc("").x.toUpperCase()); // EX
const aOrB = abc("", { flagA: Math.random() < 0.5, flagB: true });
// const aOrB FlagARet | FlagBRet
const aOrX = abc("", { flagA: Math.random() < 0.5 });
// const aOrX: FlagARet | NoFlagRet
const bOrX = abc("", { flagB: Math.random() < 0.5 });
// const bOrX: FlagBRet | NoFlagRet
const aOrBorX = abc("", { flagA: Math.random() < 0.5, flagB: Math.random() < 0.5 });
// const aOrBorX: FlagARet | FlagBRet | NoFlagRet

That all works as I expect. There are no compiler errors; at each step the compiler understands the output type of abc(). In particular, you can see that when we start passing in objects whose types are boolean instead of known true or false, the output type is itself the relevant union.


Is this perfect? No. You can always widen a value with a known property to one without that property. For example, you can assign any object type to the empty object type {}:

const flagA = {flagA: true};
let someObj: {};
someObj = flagA; // works because all objects are assignable to {}

That's bad news for my implementation of abc():

abc("", someObj).x.toUpperCase(); // no compiler error, but runtime error!
// TypeError: abc(...).x is undefined

Because someObj is no longer known to have a flagA: true property, the compiler assumes it doesn't, and therefore expects an output type of NoFlagRet. But at runtime a FlagARet comes out instead and we have a runtime error the compiler didn't catch.

I could rework abc()'s definition to be more cautious and assume that a value whose type has no explicit flagA property might in fact have a true or false value at that property anyway. But this would make abc()'s output types less useful for common use cases. It's possible this could be mitigated, and some least-bad-of-all-worlds solution exists.

But the point here is that this sort of type juggling is not simple to do and the compiler isn't really helping keep you honest. You should think if you really need this sort of functionality, and if you do, be cognizant of potential pitfalls.

Playground link to code


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to Vigges Developer Community for programmer and developer-Open, Learning and Share
...