shlajin
shlajin

Reputation: 1576

Typescript: how to make boolean work with discriminated union pair of true/false?

I have an interface

interface IData {
  importantData: string
}

and I want to add isLoading flag there. If isLoading = false, then the importantData is loaded and for sure has to be there. However, if isLoading = true, then importantData may be, or may be not there.

interface ILoadedData extends IData {
  isLoading: false
}

interface ILoadingData extends Partial<IData> {
  isLoading: true
}

So, my final type is a union of these:

type IDataWithLoading = ILoadedData | ILoadingData

If I try it works great with boolean literals

const a:IDataWithLoading = ({ isLoading: false, importantData: 'secret' })
const b:IDataWithLoading = ({ isLoading: false }) // the only error, nice
const c:IDataWithLoading = ({ isLoading: true })
const d:IDataWithLoading = ({ isLoading: true, importantData: 'secret' })

however, at compile time I don't know whether things are loading or not, so:

const random = Math.random() > 0.5
const e:IDataWithLoading = ({ isLoading: random, importantData: 'secret' }) // doesn't work

It complaints that types are incompatible. It kind of makes sense, since I declared cases for true and false, not for boolean. However, I covered every possible case for boolean, so I sense that TypeScript can understand that and I sense I'm doing something wrong.

If I declare boolean explicitly, however,

interface ILoadingData extends Partial<IData> {
  isLoading: boolean
}

it'll match isLoading = false with Partial<IData>, which is not what I want. What should I do?

Upvotes: 8

Views: 5747

Answers (2)

jcalz
jcalz

Reputation: 328533

UPDATE: 2019-05-30 with the release of TypeScript 3.5 this should be addressed by smarter union type checking. The following applies to 3.4 and below:


This is a known limitation of TypeScript. The compiler makes no attempt to propagate unions down into properties. Usually such a propagation can't happen (e.g., {a: string, b: string} | {a: number, b: number} cannot be reduced to {a: string | number, b: string | number} or anything else more useful). Even in the cases like yours where it is possible to do something, it's not cost-effective for the compiler to make the effort. Usually cases like this come down to manually guiding the compiler through possible states.

For example, you could try this:

interface ILoadedDataButNotSureYet extends IData {
  isLoading: boolean;
}

interface ILoadedData extends ILoadedDataButNotSureYet {
  isLoading: false
}

So an ILoadedDataButNotSureYet does have data loaded but isLoading might be true or false. Then, you can express IDataWithLoading as:

type IDataWithLoading = ILoadedDataButNotSureYet | ILoadingData;

This is equivalent to your original definition, but good luck trying to get the compiler to notice that. Anyway, all the specific instances you mentioned still work:

const a: IDataWithLoading = ({ isLoading: false, importantData: 'secret' })
const b: IDataWithLoading = ({ isLoading: false }) // the only error, nice
const c: IDataWithLoading = ({ isLoading: true })
const d: IDataWithLoading = ({ isLoading: true, importantData: 'secret' })

but the last one also works:

const random = Math.random() > 0.5
const e: IDataWithLoading = ({ isLoading: random, importantData: 'secret' }) // works

You might or might not want to actually change IDataWithLoading. Another way to go is to keep the original definition and just slap the compiler hard enough until it understands that your code is safe. It's not pretty though:

const eLit = { isLoading: random, importantData: 'secret' };
const e: IDataWithLoading = eLit.isLoading ? 
  {...eLit, isLoading: eLit.isLoading} : {...eLit, isLoading: eLit.isLoading};
// works, but is ugly

Yuck. Here we are using object spread to copy properties, and a separate isLoading property to take advantage of the type guarding that happens when you inspect eLit.isLoading. The ternary operator is redundant (both the "then" and "else" clauses are identical) but the compiler gets the message that in each case the value matches IDataWithLoading. As I said, yuck.


Finally, you could just decide that you're smarter than the compiler and use a type assertion to make it be quiet. This has the advantage of not making you jump through hoops, with the disadvantage that the compiler isn't helping you maintain type safety here:

const e = { 
 isLoading: random, 
 importantData: 'secret' 
} as IDataWithLoading; // Take that, compiler!

It's up to you how to proceed. Hope that helps; good luck!

Upvotes: 5

Nurbol Alpysbayev
Nurbol Alpysbayev

Reputation: 21891

The problem is that you are using Typescript for a wrong task. TS is about types, not values, so it cannot know

whether things are loading or not

So your interface should look just like this

interface IData {
  isLoading: boolean
  importantData: string | null
}

Update

I'm trying to make TS complain if it sees isLoading=false and no importantData and do not complain if it sees isLoading=true regardless of importantData presence. I am 100% sure it's possible.

Replacing this part of your code:

const random = Math.random() > 0.5
const e: IDataWithLoading = ({ isLoading: random, importantData: 'secret' }) 

To this:

const loaded = Math.random() > 0.5

let e:IDataWithLoading

if(loaded) {  
  e = { isLoading: false, importantData: 'secret' }
} else {
  e = { isLoading: true, importantData: undefined }
}

Should be all you need, if I understand you correctly.

Upvotes: -1

Related Questions