TypeScript: Part 1, Discriminated Unions

Hi! Hope you are doing well. This post is a continuation of the previous Speedrunning TypeScript post. If you haven’t read that yet, I recommend checking that out, especially the part about TypeScript generics. Let’s start it out by revisiting discriminated unions.
Discriminated union
interface StructOne { hello: string;}
interface StructTwo { world: string;}
function isValidStruct(value: StructOne | StructTwo) { // The `value` here will not have an autocomplete}
In the code snippet above, we know that `value`
will not have an autocomplete, because TypeScript does not have a way to know whether the value is of type `StructOne`
or `StructTwo`
(unless you use something like zod). A way to have autocomplete here is by using discriminated union, which is a “common ground” between the two types.
interface StructOne { kind: 'struct-one'; hello: string;}
interface StructTwo { kind: 'struct-two'; world: string;}
function isValidStruct(value: StructOne | StructTwo) { if (value.kind === 'struct-one') { return value.hello; }
return value.world;}
With the above setup, we can determine the type inside a function during build time. An example of a discriminated union that we might often use is Promise.allSettled
, where if the promise result is `fulfilled`
, then there is `value`
field, otherwise there is `reason`
field.
What we can do with functions returning unions
With us refreshed on discriminated unions, let’s revisit the function that returns unions, example as follows, which you can also see in TypeScript Playground.
enum TypeToReturn { STRING, NUMBER, OBJECT}
function testFunction(type: TypeToReturn) { switch (type) { case TypeToReturn.NUMBER: return Math.random(); case TypeToReturn.STRING: return Math.random().toString(); }
return { hello: 123 };}
const value = testFunction(TypeToReturn.NUMBER);// ^ string | number | { hello: number; }
The above is just not good, right? Because despite that we pass `TypeToReturn.NUMBER`
as the function argument and we know that it should return a `number`
, but TypeScript thinks that all the values are of equal union. So, how do we make sure TypeScript returns the expected type? The first clue is to force TypeScript to derive type as-is.
Making TypeScript derive type as-is
By default, TypeScript will try to “generalize” the type of the variables we defined. Take this example, which you can access in this Playground link:
const array = [1, '1', { hello: 123 }];// ^ (string | number | Array<{ hello: number; }>)
Notice that TypeScript derives the values to be the most common type possible. What if we want TypeScript to derive the types as-is and not generalize them? We can use `as const`
when defining the variable. Here’s the Playground link.
const array = [1, '1', { hello: 123 }] as const;// ^ const array: readonly [1, "1", {// readonly hello: 123;// }]
const [valueNumber, valueString, valueObject] = array;// ^ The type is 1, '1', and { hello: 123 }, respectively.
So, based on what we learned above, our objective is to make TypeScript “not generalize” the types returned from `testFunction`
, so that each `testFunction`
will return the the type that we want it to be, depending on the passed function arguments.
That’s the first clue, but that’s not enough. There is a second one, which you can explore in this very good post by Kent C. Dodds: Inversion of Control.
What does this inversion thing do, anyway?
Let’s take an example of the function in the snippet above. It receives a `type`
parameter, which it expects to return a value of the same type. It’s like we are “hiding” the function behind a magic box: “Here, I give you a number
argument. Now, do what you want with it and make sure it returns a number
”. Something like that.
Based on the “Inversion of Control” article above, we should “lift” the logic to the function parameter. Let’s try it! Here’s the playground link.
function testFunction(cb: () => string | number | object) { return cb();}
const value = testFunction(() => '123');// ^ string | number | object
So, what we did above is to change the logic inside the function to be in the function argument instead. However, the result is not as we want it to be yet. Well, when we want a function to be “dynamically typed”, what do we use? That’s right, generics!
function testFunction<ReturnType>(cb: () => ReturnType) { return cb();}
const value = testFunction(() => '123');// ^ stringconst value2 = testFunction(() => 123);// ^ numberconst value3 = testFunction(() => ({ hello: 123 }));// ^ { hello: number; }
As we see above, using TypeScript generics, we can have TypeScript detect the correct return type according to what we “feed” to the function. I know, I know, the example above is a very contrived one. I have an example of what we might use pretty often. Check this out! Also, again, here’s the playground link.
fetchResources();
async function fetchResources() { const [valueString, valueNumber, valueObject] = await Promise.allSettled([ fetchAllResourcesOfType(fetchResourceString), fetchAllResourcesOfType(fetchResourceNumber), fetchAllResourcesOfType(fetchResourceObject) ]);
console.log(valueString, valueNumber, valueObject);}
interface FetchResponseType<DataType> { data: DataType[]; paging: { nextOffset: number; hasNext: boolean };}
async function fetchAllResourcesOfType< DataType, ReturnType extends FetchResponseType<DataType>>(cb: (offset: number) => Promise<ReturnType>) { const allData: ReturnType['data'] = []; let hasNext = true; let nextOffset = 0;
while (hasNext) { const { data, paging } = await cb(nextOffset);
allData.push(...data); hasNext = paging.hasNext; nextOffset = paging.nextOffset; }
return allData;}
function fetchResourceString( offset: number): Promise<FetchResponseType<string>> { return new Promise((res) => { setTimeout(() => { const hasNext = offset < 3;
res({ data: ['123'], paging: { nextOffset: hasNext ? offset + 1 : offset, hasNext } }); }, 500); });}
function fetchResourceNumber( offset: number): Promise<FetchResponseType<number>> { return new Promise((res) => { setTimeout(() => { const hasNext = offset < 3;
res({ data: [123], paging: { nextOffset: hasNext ? offset + 1 : offset, hasNext } }); }, 500); });}
function fetchResourceObject( offset: number): Promise<FetchResponseType<{ hello: number }>> { return new Promise((res) => { setTimeout(() => { const hasNext = offset < 3;
res({ data: [{ hello: 123 }], paging: { nextOffset: hasNext ? offset + 1 : offset, hasNext } }); }, 500); });}
The long snippet above simulates the case where we want to fetch all entries from 3 different resources. Without inversion of control, the result will be like this:
const [valueString, valueNumber, valueObject] = await Promise.allSettled([ // ^ PromiseSettledResult<string[] | number[] | { hello: number }[]> fetchAllResourcesOfType(fetchResourceString), fetchAllResourcesOfType(fetchResourceNumber), fetchAllResourcesOfType(fetchResourceObject)]);
Whereas, with inversion of control, it is now a tuple:
const [valueString, valueNumber, valueObject] = await Promise.allSettled([ // ^ [PromiseSettledResult<string[]>, PromiseSettledResult<number[]>, PromiseSettledResult<{ // hello: number; // }[]>] fetchAllResourcesOfType(fetchResourceString), fetchAllResourcesOfType(fetchResourceNumber), fetchAllResourcesOfType(fetchResourceObject)]);
The tuple here is important, because we know the first array element’s promise settled value contains `string[]`
, the second contains `number[]`
and the third one contains `Array<{ hello: number }>`
.
By doing this, you do not need weird dances such as `as unknown as SomeType`
or `as any`
. The best part of not using `any`
? You don’t have to redeclare the type somewhere else and you know it’s surely type-safe (during build time, anyway).
Summary
Alright, let’s recap what we learn:
- Discriminated union is used to “force” TypeScript to create a distinction between 2 or more types by creating a “common literal field”.
- TypeScript will generalize a variable’s type unless the variable definition is suffixed with
`as const`
which then its type will be inferred as-is. - Inversion of control is used to “lift” the logic inside a function to the function argument, where the caller has more control.
- By combining the concepts of as-is type inferring and inversion of control, we can achieve safer typing in our codebase.
Hopefully, that’s useful, thank you for reading this far and I’ll see you on the next post!