dev-resources.site
for different kinds of informations.
TypeScript's Lack of Naming Types and Type Conversion in Angular
Random thoughts on TypeScript. Iâve noticed 2 things working on a larger Angular project:
- it is not common to name (aka alias) types
- type conversion either doesnât happen, or does with not a lot of safety
Not Naming/Aliasing Types
My 2 years spent in soundly typed languages, and 1 year spent in BFFâs we wrote using Zod, naming your types, and co-locating them next to the code that uses them is super normalized for me.
React? You name the type for the props, then use it in the props:
type UserProfileProps = { name: string, age: number }
const UserProfile = (props:UserProfileProps) =>
JSON? You create a Zod schema, and have a type of the same name:
cons User = z.object({
name: z.string(),
age: z.number()
})
type User = z.infer<typeof User>
Function can succeed or fail, but youâre not sure the return type so you use a generic?
type Result<T> = Ok<T> | Err
type Ok = { type: 'ok', data: T }
type Err = { type: 'err', error: string }
const parseUser = (json:any):Result<User> => {
const result = User.safeParse(json)
if(result.success === false) {
return Err(result.error)
} else {
return Ok(result.data)
}
}
Youâll notice in all 3, a couple patterns:
- the type has an explicit name
- the type is co-located (e.g. ânearâ) where itâs used
What Iâm noticing is that most developers Iâm working with DO NOT name their types. There are many who _are_ naming their Enums, specifically ones used to register the numerous magic strings we UI developers have to deal with⌠but theyâre placed in âModelâ modules, I _think_ a throwback to the MVC (aka Model View Controller) days where the Model is your âdataâ, even though Enum holds no data, itâs merely definitions for getting data.
To me this is⌠weird, uncomfortable, and seems like DRY (donât repeat yourself) doesnât apply to types, but only to variables/functions/classes. Which in turn makes me go âwaaaaaaat!?â.
Elm, ReScript, F#, Ocaml, Scala⌠itâs just normal to name your types, then use them places. In fact, youâll often create the types _before_ the code, even if youâre not really practicing DDD (Domain Driven Design). Yes, youâll do many after the fact when doing functions, or you start testing things and decide to change your design, and make new types. Either way, itâs just âthe normâ. You then do the other norms like âname your functionâ and âname your variablesâ. Iâm a bit confused why itâs only 2 out of 3 (variables and functions, not types) in this TypeScript Angular project. Iâll have to look at other internal Angular projects and see if itâs common there as well.
Oddly, I remember both Elm and ReScript DO have the capability of NOT naming your types. Like instead of user like the above, both support:
getUser : { name:string, age: number }
Youâll notice itâs not getUser: User
. You can just create your own Objects, in both Elm and ReScript, just like TypeScript. Youâll see this anonymously defined types everywhere:
getUser : { name:string, age: number }
instead of:
type NameOrNothing = string | undefined
loadSomeData():NameOrNothing
My favorite are the really well thought out Record types that are used 7 times in a file; to me itâs a super easy, super low hanging fruit of hitting DRY⌠and yetâŚ.
getData():FormData<Record<FormInput, DataResult>>
processForm(record:FormData<Record<FormInput, DataResult>>)
logAlternative(record:FormData<Record<FormInput, DataResult>>):Observable<FormData<Record<FormInput, DataResult>>>
⌠instead of:
type FormRecord = FormData<Record<FormInput, DataResult>>
getData():FormRecord
processForm(record:FormRecord)
logAlternative(record:FormRecord):Observable<FormRecord>
This is also accidentally encouraging primitive type obsession, but thankfully age has helped me refrain from crying about that in PRâs⌠for now.
Little to No Safe Type Conversion
This one has been really giving me anxiety on a variety of PRâs for the past few months. There are places where we have to do some TypeScript type narrowing⌠and we either donât, or donât narrow enough. For those of you not from TypeScript, or gradually typed languages, when we say âtype narrowingâ, we donât mean âWe use typesâ to narrow our problem space. We mean ânarrowingâ in the context of making the types _more_ correct. For those of you in typed languages where youâve learned to avoid primitive obsession, itâs kind of like that.
TypeScript is one of those languages that is gradually typed, which means you can increase or decrease the level of typing. If you donât know, or know, that something can be anything, you use a type called âanyâ or âunknownâ. If you know something is ALWAYS a String, then you can use the type âstringâ. However, how do you convert something from an âanyâ to a âstringâ? What if itâs _already_ a string? This is what TypeScript calls type narrowing; ensuring the types you get that arenât narrowed and are considered wide (a la a wide variety of types it _could_ be), to a narrow one (only 1 or 2 types it probably _is_).
Many of these type narrowing techniques _look_ like runtime assertions/checks, and some are. Some arenât. Both help TypeScript, and you the dev, ensure the types match up.
Whatâs not really talked about, though, is converting types. Most of this, at least in the TypeScript documentation, is considered under the type narrowing domain, but not all of it. Some of it is basic JavaScript.
For example, converting an âanyâ to a âstringâ, can go in 1 of 3 ways: a type assertion, a type guard, or a type predicate. The assertion, using the as
keyword, is the most dangerous. Itâs you, the dev, telling TypeScript what it is, and it can trust you 100%. There is no further type checking done; effectively turning type checking not OFF, but âbelieving what you sayâ. Obviously, someone whoâs experienced the joy of soundly typed, nominal type systems (as opposed to TypeScript which is strict, gradually typed, and structurally typed) seeâs this as terrifying:
const name = json as string
Now, those of you from structural type systems, like Java/C#/Go, some of this may be nuanced, and not as black and white as I claim. âIf it walks and talks like a Duck⌠itâs a Duckâ. There are many cases where the dev, using as
, is correct. Sometimes theyâre even âcorrect enoughâ.
The 2nd, better one, at least for primitives, is a type guard, like:
if(typeof json === 'string') {
console.log("it's a string")
} else {
console.log("it's not a string")
}
Combining these together gives you, and TypeScript, a LOT more confidence the type is what you actually think it is. There are many minefields here (e.g. typeof []
is âobjectâ, not Array, hence Array.isArray
existing), but itâs worlds better then âI know better than the compilerâ.
Finally, there are type predicates; functions that return true or false. These predicates, however, have a unique return type; a type assertion:
function isString(json:any):json is string {
return typeof json === 'string'
}
Notice itâs âisâ string, not âinstanceofâ string, which is also a minefield. Importantly, you can nest a lot of type guards in these functions to greatly help TypeScript.
So why do these 3 type narrowing techniques (there are more) matter when talking about converting types in TypeScript? Well, you can probably see how you can safely convert any to string⌠but what about an any to a User without something like Zod?
const json:any = ???
// convert the above to this
type User = {
name: string,
age: number
}
You have to utilize type narrowing to first ensure any matches the structure you need, then you can either case with as if possible other fields there wonât mess up your code (e.g. Object.keys expecting only 2 fields, not more), OR assemble it by hand, like:
if(typeof json === 'object'
&& typeof json.name === 'string'
&& typeof json.age === 'number'
&& isNaN(json.age) === false
... // and even more
return { name: json.name, age: json.age }
That is hard for a few reasons:
- thatâs a lot of code
- thatâs a lot of knowledge of low-level JavaScript primitives
- debugging this requires both adding them 1 at a time and waiting for the TypeScript language server AND looking at the compiler errors to adjust
- devs will immediately start failing to see the ROI of strict typing given the amount of work they need to do just to reach a strict, NOT SOUND, level of assurance
⌠#4 I think is the killer. I canât confirm this, just a hunch.
Other Reasons Devs May Not Convert Types Safely
There are a variety of things that contribute to this problem on larger code bases, as well.
No Domain Modelling
The first is the lack of domain modelling from 3rd party sources, something us UI devs deal with a lot, and made worse when weâre not the ones writing our own BFF. Devs default to primitives or less narrowed types when dealing with 3rd party data such as JSON from REST calls or Objects in LocalStorage. Theyâll use string or any
or Record<string, unknown>
. That puts the onus on anyone who uses those types to narrow further.
âLook man, I parsed the JSON⌠you donât like the success in the Observable, thatâs not my problemâ.
âWhy not go ask the API team what JSON they respond with?â
âThey donât know, teamâs had attribution, and I donât even think there is code thatâs intelligible that responds with that JSON⌠I think itâs config driven, so hard to quickly seeâ.
âOh⌠myâŚâ
What is Type Driven Development?
The 2nd reason is thereâs tons of literature and discussion out there about Test Driven Development, but not Type Driven Development. I knew about Elixir, Haskell, OCaml, F#, and Scala for many years, and only first heard of Type Driven Development when I learned Elm. Armed with that knowledge, I found out other soundly typed languages, even Rust devs, did the same thing as the Haskell, OCaml, F#, and Scala devs: often defined their types first, even before the tests sometimes. They just âdid itâ and didnât have a name for it.
So many devs havenât heard about things like âmaking impossible situations impossible through typesâ or âDomain Driven Design without classesâ or âavoiding primitive obsessionâ, or using types to negate how many unit tests you need to write, and enabling the ability to write other types of tests like property/fuzz tests.
Generics are More Popular Than Exporting Narrowed Types
The 3rd reason is many frameworks and libraries in the past few years, despite the huge rise of TypeScript, seem to ignore narrowed types. In fact, many have heavily leveraged generics for obvious reasons: allowing developers to not be constrained by their designs so the library/framework is easier to use. This helps with adoption, but fails to help with type education.
Some even go the opposite direction; in the case of Redux, there isnât a union type to be found despite Redux being a conversion of Elm to JavaScript, and later to TypeScript. The whole point of Unions is ensure youâre Reducers can only do â1 of 3 thingsâ. Instead, youâll see String constants, or _maybe_ an Enum if youâre lucky. Often itâs dangerous Records used to narrow a domain when a Union should have been used instead.
Conclusions
Iâm not sure what can be done on the lack of naming types beyond leading by example, and co-locating the types to where theyâre used.
For type conversions, thatâs easy: use Zod.
Featured ones: