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.