DEV Community

Shane Osbourne
Shane Osbourne

Posted on

Type `Assertions` vs `Declarations` - the dangers of `as` in Typescript

Since this has caught me out a number of time recently, I decided it's time to add another resource to any future search results :)

/**
 * Let's say you're declaring the 'shape' of messages
 * that your application can accept in a union like so...
 */
type Msg =
    | { kind: "sign-in"; email: string; password: string }
    | { kind: "sign-out" };

Enter fullscreen mode Exit fullscreen mode

Type 'Assertions'

The dangerous kind. Typescript allows the following snippet and does not warn us that the email and password fields are absent.

It's very easy to imagine the bugs introduced later when handling a sign-in message and expecting email and password to exist.

/**
 * Ooops! These `As Expressions` force Typescript to believe *us* that
 * `msg1` & `msg2` are members of the `Msg` union above. 
 */
const msg1 = { kind: "sign-in" } as Msg;
const msg2 = <Msg>{ kind: "sign-in" };
Enter fullscreen mode Exit fullscreen mode

So, even though { kind: "sign-in" } is NOT actually a valid Msg - it only could be if the email & password were also present - that doesn't stop Typescript from allowing it in any future place that accepts Msg.

This means that when you attempt to narrow the Msg union later - such as in a switch statement - you could run into errors when you expect email and password to actually exist.

Use with caution, and only when you think you know more than
Typescript (see: hardly ever đŸ¤£)

Type 'Declarations'

The safer way. Use this instead when you want Typescript to actually error when the element in question does not satify the type exactly.

/**
 * Better! Typescript will warn us here that `msg3` is invalid.
 * Adding the `email` and `password` fields would remove the error đŸ˜€
 */
const msg3: Msg = { kind: "sign-in" }; // Error!
Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
andrekovac profile image
André Kovac

Thanks for this very nice example! I understand the difference for complex types (objects, unions etc.)

Is there a difference of a type assertion and a type declaration in the following three cases?

  1. For primitive types like string, for example if type Msg = string?
  2. For Arrays of primitive types like string[], for example if type Msg = string[]?
  3. In case of any, i.e const msg: any = ... and const msg = ... as any?

If there is no difference there is no danger in using a type assertion in these cases, right?

P.S. You use the term "type declaration" - you're referring to a "type annotation", right?

Collapse
 
jwp profile image
John Peters

I do not like the ability to define different types for the same object. It is, to me, already in place with type any. If we insist on different types they should be contained not inherited in my opinion. So in this case Msg could contain either a login or logout type. That way there's never any potential problem and it carries almost no extra overhead.

Collapse
 
shakyshane profile image
Shane Osbourne

There's no 'inheritance' involved in this approach, Typescript has good support for tagged/discriminated unions - it's only the addition of 'as' here that causes problems.

This style is often used by devs familiar with similar things in other languages, like in Rust, it would be

enum Msg { 
  SignIn { email: String, password: String },
  SignOut
}
Collapse
 
jwp profile image
John Peters • Edited

You're right, I used the term inheritance; incorrectly to describe a pattern based on the single strong type. It is a type at layer zero, which could be either inherited or contained.

If we define something to be more than one type, and we don't use a containment pattern then, we are propelled to use the typescript multi-type system, which you show here.

When we use a field within an object to describe the type, we still achieve polymorphism, but we have the potential to run into issues as you describe here. I've seen this mistake happen over in older JavaScript code, where the freedom of creating objects, anywhere, anytime, anyplace causes issues because of missed properties, property key's being misspelled in some of the objects, expecting functions that aren't there; and the issues with not following a type system. This is why I prefer strong types and the containment pattern like this:


Msg {
   logIn:SignIn     //from the enum or other strong type
   logOut:SignIn  // from the enum or other strong type

The problem of accidental type confusion is resolved because both logIn and logOut are typed. Any attempt to sidestep the type constraint is immediately caught at code time. But here, we would need a discriminator as we have both types contained and we are easily able to determine their state.

 if(Msg.Login){ post(Msg.Login)}
 if(Msg.Logout){ post{Msg.Logout)}

I see the 'as' operator to be a casting mechanism. When it's used with non-strongly typed code, then the issue you describe is more than just a typescript error, it's how JavaScript has been operating with the loosely typed system for 20 years now.

I did gain more insight from your article on the newer polymorpic behaviors that Typescript is now support. Thanks I'll look into it more.