If you've ever used languages like Elm or ReasonML for writing any front-end application then you are probably familiar with the terms Tagged Union, Variants or even Discriminated Unions, but if it is not the case, let me show what i'm referring to:
-- Full code example at: https://ellie-app.com/cYzXCP7WnNDa1
-- FieldType is a Tagged union.
type FieldType
= Editable
| ViewOnly
init : () -> (Model, Cmd Msg)
init _ =
(
initial,
(ViewOnly, "Welcome to ELM")
|> Task.succeed
|> Task.perform Init
)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Init (kind, value) ->
(
{ model | value = value, kind = kind }
, Cmd.none
)
Handle value ->
(
{ model | value = value }
, Cmd.none
)
view : Model -> Html Msg
view { kind, value } =
case kind of
Editable ->
div []
[
input [onInput Handle] []
, h1 [] [text ("Value: " ++ value)]
]
ViewOnly ->
div [] [ h1 [] [ text value ] ]
The code above displays one of Elm's main strengths when we talk about modeling your application based on data types.
Don't be afraid of all the boilerplate, the main point here is how we have a completely agnostic view
while also being 100% sure that our model can't and won't be in a undetermined state or have any missing props, never.
Our model property kind
will never contain anything different from a FieldType
and with the help of the Elm compiler we could rest assure that our view will also be reliable and always have all the needed data.
Typescript
Today, Typescript have been massively used as a tool which helps in minimize some runtime errors and give some guarantees about what exactly are our Data inside the sea of uncertainty that is Javascript code.
That being said, let's take a look on how commonly components are validated in some React with Typescript code bases:
// FieldType could also be just the strings values.
enum FieldType {
VIEW_ONLY = "viewOnly",
EDITABLE = "editable"
};
type Props = {
kind: FieldType;
onChange: (_: ChangeEvent<HTMLInputElement>) => void;
name?: string;
value: string;
};
const Field: VFC<Props> = (props) => {
// ...component implementation
};
The compiler will prevent you from use the component without the required props, but, do you really need a onChange
function if you just want a non editable field?
What about any new member which enters the team, how will this component plays when someone with no deep understanding of every and each component in the code base tries to use it somewhere else?
Sure, the code above just shows a simple Field
component, nothing that we couldn't reason about just reading the code, but, it is far from a good implementation if you do want to respect the props, the component behavior for each kind of implementation and how it will play when it is needed somewhere else.
Tagged Unions for the rescue.
"Talk is cheap, show me the code", Linus Torvalds
enum FieldType {
VIEW_ONLY = "viewOnly",
EDITABLE = "editable"
};
type BaseProps = {
kind: FieldType;
name?: string;
value: string;
};
type Editable = {
kind: FieldType.EDITABLE;
onChange: (_: ChangeEvent<HTMLInputElement>) => void;
} & BaseProps;
type ViewOnly = {
kind: FieldType.VIEWONLY;
} & BaseProps;
type Props = ViewOnly | Editable;
const Field: VFC<Props> = (props) => {
const { value, name, kind } = props;
const { onChange } = props as Editable;
// ...component implementation
}
Now, we have some extra type boilerplate but our component props would be respected and enforced by a known FieldType
just like we intended to when the component was implemented. Now, what if we try to call our component without any property? what will happen?
Well, at first, Typescript will show you an error in compile time;
Type '{}' is not assignable to type '(IntrinsicAttributes & { kind: FieldType.VIEW_ONLY; } & DefaultProps) | (IntrinsicAttributes & { kind: FieldType.EDITABLE; onChange: (_: ChangeEvent<...>) => void; } & DefaultProps)'.
Type '{}' is missing the following properties from type '{ kind: FieldType.EDITABLE; onChange: (_: ChangeEvent<HTMLInputElement>) => void; }': type, onChange
Then,after you provide the property kind
with a known FieldType
, it will show you which properties you still need to provide to ensure that your component has everything it needs to work as expected:
...
Property 'onChange' is missing in type '{ kind: FieldType.EDITABLE; }' but required in type '{ kind: FieldType.EDITABLE; onChange: (_: ChangeEvent<HTMLInputElement>) => void; }'.
Now, you just need to use a mapped object or a switch case in your Field component
render, which, based on the enforced prop kind
and given your nullable or not nullable props enforced by the tagged union, it'll show you exactly what needs and not needs to be handled, formatted or treated.
For the sake of reusability the FieldType
enum can be moved to a types/field
or a types/components
.
Here's an implementation for example pourposes:
Final Thoughts
IMHO that's one of the best ways to really use the Typescript compiler to help us compose code.
Not only validating our components within nullable or non nullable props
values, but also helping in the proper implementation while also being thoughtful of the ones who will come to use, maintain and update the code base.
Top comments (6)
Nice article! Simple and efficient way..
For the sake of the reader too. My eyes are bleeding.
Nice article by the way π
Dunno if i've made some english error or if you're reffering to the TS type boilerplate.
Also, thank you π.
Very good article! Thanks for sharing!
What distinguishes a "real" typed language from a "non-real" typed language?
Considering the frontend spectre, ELM and ReasonML are both languages which have typed constraints built in.
As we know, that's not the case for JS which needs Typescript/Flow to help with typing but not in a really enforced way and it leads many devs to don't really try and understand how to handle those types in a way that will be more safe or even meaningful, IMO.
Not really a matter of "real | non-real", need to edit this part asap π.