Skip to content

Instantly share code, notes, and snippets.

@zaceno
Last active June 23, 2021 01:06
Show Gist options
  • Save zaceno/e25fe832a1e11168b05dbce4a16b832b to your computer and use it in GitHub Desktop.
Save zaceno/e25fe832a1e11168b05dbce4a16b832b to your computer and use it in GitHub Desktop.
Typescript + Hyperapp examples

Hello World

import {h, text, app} from "hyperapp"

app({
  view: () => h('main', {}, text('text')),
  // cast to Node only if you're sure it exists
  node: document.getElementById('app') as Node,
})

playground

Inline, implicit actions

import {h, text, app} from "hyperapp"

app({
  init: 0,
  view: clicks => h('main', {}, [
    h('button', {onclick: x => x + 1}, text('clicked' + clicks + 'times'))
  ]),
  node: document.getElementById('app') as Node,
})

playground

Actions with Event Paylaods

import {h, app, Action} from "hyperapp"

type State = {name: string}

const HandleInput : Action<State, Event> = (state, event) => ({
  ...state,
  // since we are sure in this case, the event.target is
  // an input element, it is ok to cast. Otherwise we
  // probably should make sure.
  name: (event.target as HTMLInputElement).value 
})

app({
  init: {name: ''},
  view: state => h('main', {}, [
    h('input', {
      value: state.name,
      oninput: HandleInput
    })
  ]),
  node: document.getElementById('app') as Node,
})

playground

Action decorators, and actions with non-event payload types

import {h, app, Action} from "hyperapp"

// basic decorator type
type Decorator<From, To> = <S>(action: Action<S, To>) => Action<S, From>

// decorator for actions that take strings as payloads, and meant to be
// used on events with input-elements as targets (but I'm not sure how
// to enforce that part with types)
const withTargetValue:Decorator<Event, string> = action =>
  (_, event) =>
    [action, (event.target as HTMLInputElement).value]

// It's always a good idea to specify the state of your
// app as a type
type State = {name: string}

// Action takes string payload
const HandleInput : Action<State, string> = (state, name) => ({...state, name})

app({
  init: {name: ''},
  view: state => h('main', {}, [
    h('input', {
      value: state.name,
      // We can't use the HandleInput action directly
      // as it takes a string payload, but by using 
      // the withTargetValue decorator, the types
      // match up:
      oninput: withTargetValue(HandleInput)
    }),
  ]),
  node: document.getElementById('app') as Node,
})

playground

Actions with custom payloads

import {h, app, text, Action} from "hyperapp"

type Choice = 'A' | 'B'

type State = {choice?: Choice}

const Choose : Action<State, Choice> = (state, choice) => ({...state, choice})

// typescript can't infer the State type from 
// the default initial state {}, so we tell it:
app<State>({
  view: state => h('main', {}, [
    h('button', {onclick: [Choose, 'A']}, text('A')),
    // An invalid choice as payload makes typescript
    // complain (change to 'B' or 'A' to fix it):
    h('button', {onclick: [Choose, 'C']}, text('B')),
    h('p', {}, text('Current choice: ' + state.choice))
  ]),
  node: document.getElementById('app') as Node,
})

playground

Basic View-component

import {h, text} from "hyperapp"

type MyComponentProps = {
  foo: string
  bar: number
}

const myComponent = (props: MyComponentProps) => h('div', {}, [
  props.bar > 20
    ? h('h1', {}, text(props.foo))
    : h('h3', {}, text(props.foo))
])

// no errors because we are passing correct types
myComponent({ foo: 'Yes', bar: 5 })

// has ts-errors because we're passing wrong types
myComponent({ bar: '5' })

playground

Interactive View Component

import {h, text, Action} from "hyperapp"

// By using a generic parameter (here: S), for the
// state-parameter to the actions, we ensure that
// the actions provided will operate on the same
// state-type, even though we do not know what that
// state type might be.
type MyComponentProps<S> = {
  foo: Action<S, Event>
  bar: Action<S, Event>
}

// Since we don't know the app's state in a reusable
// view component, like this the state must be inferred
// using a generic function type parameter. 
const myComponent = <S>(props: MyComponentProps<S>) => h('div', {}, [
  h('button', {onclick: props.foo}, text('Do Foo')),
  h('button', {onclick: props.bar}, text('Do Bar'))
])

// no errors because we are passing correct types
myComponent({ foo: (x:number) => x + 1, bar: (x:number) => x * 2 })

// has ts-errors because we're using mismatched action types
myComponent({ foo: (x:number) => x + 1, bar: (x:string) => x + '!'})

playground

Interactive View Components with payload validation

import {h, text, Action, ValidateCustomPayloads} from "hyperapp"

type MyComponentProps<S> = {
  foo: Action<S, Event> | [Action<S, any>, any]
  bar: Action<S, Event> | [Action<S, any>, any]
}

// Since the view component props allow actions with custom payloads
// We should use the ValidateCustomPayloads utility type to ensure
// that the provided payloads match the given action-types.
const myComponent = <S, X>(props: ValidateCustomPayloads<S, X> & MyComponentProps<S>) => h('div', {}, [
  h('button', {onclick: props.foo}, text('Do Foo')),
  h('button', {onclick: props.bar}, text('Do Bar'))
])

type State = {
  score: number,
  name: string, 
}

const SetName : Action<State, string> = (state, name) => ({...state, name})
const AddScore : Action<State, number> = (state, amount) => ({...state, score: state.score + amount})

// no errors, since payloads match their actions
myComponent({ foo: [SetName, 'unknown'], bar: [AddScore, 20] })

// error, since one payload donesn't match the actions
myComponent({ foo: [SetName, 20], bar: [AddScore, 20]})

playground

Issues with type inference in view components

import {h, text, Action, VDOM} from "hyperapp"

type MyComponentProps<S> = {
  foo: Action<S, any>, 
  bar: string
}

// In this version, typescript tries to infer the
// return type of the view component, but cannot,
// because only the first child has any actions 
// from which the state type can be inferred
// in the second child, the state type is inferred
// to be unknown.
const myComponent = <S>(props: MyComponentProps<S>) =>
  h('div', {}, [
    h('button', {onclick: props.foo}, text('click')),
    h('p', {}, text(props.bar)),
  ])

// The easiest way to solve this is to explicitly
// declare the return type:
const myComponent2 = <S>(props: MyComponentProps<S>):VDOM<S> => 
  h('div', {}, [
    h('button', {onclick: props.foo}, text('click')),
    h('p', {}, text(props.bar)),
  ])

playground

A naive fetch-effect

mport {Action, Dispatch, Effect} from "hyperapp"

// definition of getJSON effect:

type GetJSONOptions<S> = {
  url: string,
  action: Action<S, any>
  error?: Action<S, Error>
}

// here we define an effect runner. It needs the Dispatch type for the
// first argument to qualify as an effect runer.
const runGetJSON = <S>(dispatch:Dispatch<S>, options:GetJSONOptions<S>) => {
  fetch(options.url).then(response => {
    if (response.status !== 200 && options.error) throw new Error('Status Error: ' + response.status)
    return response
  })
  .then(response => response.json())
  .then(data => dispatch(options.action, data))
  .catch(e => {
    if (options.error) dispatch(options.error, e)
  })
}

// this is the effect creator. It returns the Effect type which
// is a tuple of [runner, options] where the runner should accept
// options of the specified type.
const getJSON = <S>(
  url:string,
  action:Action<S, any>,
  error?: Action<S, Error>
):Effect<S, GetJSONOptions<S>> => [
  runGetJSON,
  {url, action, error}
]

// -----------------

// usage in actions:

type State = {
  fetching: boolean,
  data: any 
}

// this action fetches some data
const FetchData :Action<State> = (state: State) => [
  {...state, fetching: true, data: null},
  getJSON('http://example.com/data', GotData)
]

// this action saves response data on the state
const GotData : Action<State, any> = (state, data) => ({
  ...state,
  fetching: false,
  data
})

playground

Type-checking the Payload response in fetch effect

import {Action, Dispatch, Effect} from "hyperapp"

// definition of getJSON effect:

// Through the type parameter D, we specify the type of the
// Payload we expect to get back
type GetJSONOptions<S, D> = {
  url: string,
  action: Action<S, D>
  error?: Action<S, Error>
}

// The type parameter D needs to be an type parameter to this function as well, 
const runGetJSON = <S, D>(dispatch:Dispatch<S>, options:GetJSONOptions<S, D>) => {
  fetch(options.url).then(response => {
    if (response.status !== 200 && options.error) throw new Error('Status Error: ' + response.status)
    return response
  })
  .then(response => response.json())
  // we can not have type safetyp about  what an API will provide
  // we have to take it on faith that the type of response will
  // be the one we asked for.
  .then(data => dispatch(options.action, data as D))
  .catch(e => {
    if (options.error) dispatch(options.error, e)
  })
}

// By passing D along from this main interface to the
// effect, we allow users to state what type of payload
// they expect back
const getJSON = <S, D>(
  url:string,
  action:Action<S, D>,
  error?: Action<S, Error>
):Effect<S, GetJSONOptions<S, D>> => [
  runGetJSON,
  {url, action, error}
]

// -----------------

// usage in actions:

type DataA = {foo: number}

type DataB = {bar: string}

type State = {
  fetching: boolean,
  data: null | DataA | DataB 
}

const FetchDataA :Action<State> = (state: State) => [
  {...state, fetching: true, data: null},
  getJSON<State, DataA>('http://example.com/data-a', GotDataA)
]

const FetchDataB :Action<State> = (state: State) => [
  {...state, fetching: true, data: null},

  // Notice the error here: We said we expect data of type DataB
  // but the callback action we provided expects data of type
  // DataA. Fix it by changing the action to GotDataB,
  // or the expected type to DataA.
  getJSON<State, DataB>('http://example.com/data-b', GotDataA)
]

const GotDataA : Action<State, DataA> = (state, data) =>
  ({ ...state, fetching: false, data })

const GotDataB : Action<State, DataB> = (state, data) => 
  ({ ...state, fetching: false, data })

playground

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment