Utilizing control flow analysis to narrow the type of journal
by inspecting the value of journalState.journal.type
within the function updateJournalState()
is an ineffective approach for two main reasons:
TypeScript does not adjust or re-restrict generic type parameters like T
through control flow analysis. Although you can narrow a value of type T
to a more specific type, T
itself remains unchanged. Therefore, even if you narrow journalState
, it will have no impact on T
and consequently no effect on journal
. There are ongoing discussions on GitHub, such as microsoft/TypeScript#33014, regarding potential improvements in this area, but as of now, no changes have been implemented.
Even if the function was not generic, the type of journalState
would likely be something along the lines of
JournalState<SportJournal> | JournalState<ArtJournal>
, which does not qualify as a discriminated union. Although the journal.type
subproperty could be viewed as a discriminator, TypeScript only supports discriminant properties at the top level of the object and does not delve into subproperties for discriminating unions. A recurring request has been made on microsoft/TypeScript#18758 to support nested discriminated unions, yet there have been no implementations thus far.
To address these challenges, alternative methods may need to be explored, albeit potentially leading to complex solutions.
Alternatively, I suggest enhancing the generality of the function and substituting control flow branching (if
/else
) with a unified generic lookup that adheres to compiler standards. This modification necessitates particular refactoring steps, as outlined in microsoft/TypeScript#47109. The concept involves initiating a fundamental key-value type structure:
interface JournalMap {
S: { sport: boolean, id: string },
A: { art: boolean, id: string }
}
and expressing intended actions using mapped types over this base type alongside generic indexes into said mapped types.
For instance, defining Journal
can follow this format:
type Journal<K extends keyof JournalMap = keyof JournalMap> =
{ [P in K]: { type: P } & JournalMap[P] }[K]
This creates a distributive object type, ensuring that while Journal
collectively forms a union, individual members like Journal<"S">
and Journal<"A">
become distinct entities. If preferred, aliases can be assigned to these individual components:
type SportJournal = Journal<"S">;
type ArtJournal = Journal<"A">;
A similar definition to the previous example can be applied to JournalState
:
type JournalState<T extends Journal<any>> =
{ state: string, journal: T }
In lieu of conditional statements like if
/else
, an object containing updater functions must be created, enabling indexing with either "S"
or "A"
:
const journalUpdaters: {
[K in keyof JournalMap]: (journal: Partial<Journal<K>>) => void
} = {
S: journal => console.log(journal, journal.sport),
A: journal => console.log(journal, journal.art)
}
This explicit assignment of the mapped type ensures that the compiler understands the relationship between K
being a generic type and the resulting function type, preventing unnecessary unions.
Finally, a generic function can be utilized:
const updateJournalState = <K extends keyof JournalMap>(
journalState: JournalState<Journal<K>>, journal: Partial<Journal<K>>) => {
journalUpdaters[journalState.journal.type](journal); // valid
}
This code segment compiles error-free. By inferring that journalState.journal.type
corresponds to type K
, the compiler recognizes that
journalUpdates[journalState.journal.type]
aligns with type
(journal: Partial<Journal<K>>) => void
. Consequently, given that
journal
pertains to type
Partial<Journal<K>>
, the function call is permitted.
The functionality can be validated from the caller's perspective:
const journalStateSport: JournalState<SportJournal> = {
state: "A",
journal: { type: "S", sport: true, id: "id" },
};
updateJournalState(journalStateSport, { id: "a", sport: false }); // permissible
updateJournalState(journalStateSport, { art: false }) // error!
const journalStateArt: JournalState<ArtJournal> = {
state: "Z",
journal: { type: "A", art: true, id: "xx" }
};
updateJournalState(journalStateArt, { art: false }); // acceptable
updateJournalState(journalStateArt, { id: "a", sport: false }); // error!
Upon testing, it confirms that the compiler approves correct calls and flags incorrect ones accordingly.
Link to playground for code demonstration